Compare commits

...
96 Commits
Author SHA1 Message Date
Kit Langton 839efafad6 refactor(summary): remove unnecessary Layer.unwrap from defaultLayer 2026-04-01 14:21:29 -04:00
Kit Langton c619caefdd fix(account): coalesce concurrent console token refreshes (#20503) 2026-04-01 13:16:35 -04:00
Kit Langton c559af51ce test(app): migrate more e2e suites to isolated backend (#20505) 2026-04-01 13:15:42 -04:00
github-actions[bot] d1e0a4640c Update VOUCHED list
https://github.com/anomalyco/opencode/issues/20482#issuecomment-4171492178
2026-04-01 16:50:21 +00:00
opencode-agent[bot] f9e71ec515 chore: update nix node_modules hashes 2026-04-01 16:47:33 +00:00
opencode-agent[bot] ef538c9707 chore: generate 2026-04-01 16:14:37 +00:00
Kit Langton 2f405daa98 refactor: use Effect services instead of async facades in provider, auth, and file (#20480) 2026-04-01 16:13:13 +00:00
Kit Langton a9c85b7c27 refactor(shell): use Effect ChildProcess for shell command execution (#20494) 2026-04-01 12:07:57 -04:00
Shoubhit Dash 897d83c589 refactor(init): tighten AGENTS guidance (#20422) 2026-04-01 21:37:25 +05:30
opencode-agent[bot] 0a125e5d4d chore: generate 2026-04-01 15:59:28 +00:00
Kit Langton 38d2276592 test(e2e): isolate prompt tests with per-worker backend (#20464) 2026-04-01 15:58:11 +00:00
Dax Raad d58004a864 fall back to first agent if last used agent is not available 2026-04-01 11:09:29 -04:00
Kit Langton 5fd833aa18 refactor: standardize InstanceState variable name to state (#20267) 2026-04-01 10:39:43 -04:00
Shoubhit Dash 44f83015cd perf(review): defer offscreen diff mounts (#20469) 2026-04-01 19:29:12 +05:30
Kit Langton 9a1c9ae15a test(app): route prompt e2e through mock llm (#20383) 2026-04-01 08:28:38 -04:00
Shoubhit Dash a3a6cf1c07 feat(comments): support file mentions (#20447) 2026-04-01 16:11:57 +05:30
Shoubhit Dash 47a676111a fix(session): add keyboard support to question dock (#20439) 2026-04-01 15:47:15 +05:30
Brendan Allan 1df5ad470a app: try to hide autofill popups in prompt input (#20197) 2026-04-01 08:43:03 +00:00
Brendan Allan 506dd75818 electron: port mergeShellEnv logic from tauri (#20192) 2026-04-01 07:01:44 +00:00
Kit Langton c8ecd64022 test(app): add mock llm e2e fixture (#20375) 2026-03-31 21:24:39 -04:00
opencode-agent[bot] ca376a4cff chore: update nix node_modules hashes 2026-04-01 01:15:51 +00:00
Kit Langton 7532d99e5b test: finish HTTP mock processor coverage (#20372) 2026-04-01 00:45:42 +00:00
Kit Langton 181b5f6236 refactor(prompt): use Provider service in effect layers (#20167) 2026-04-01 00:44:15 +00:00
opencode 6314f09c14 release: v1.3.13 2026-04-01 00:44:06 +00:00
Sebastian 4b4b7832aa upgrade opentui to 0.1.95 (#20369) 2026-04-01 01:53:05 +02:00
opencode-agent[bot] 4280307013 chore: update nix node_modules hashes 2026-03-31 23:19:18 +00:00
opencode-agent[bot] 9b09a7e766 chore: generate 2026-03-31 23:15:56 +00:00
Kit Langton 3fc0367b93 refactor(session): effectify SessionRevert service (#20143) 2026-03-31 19:14:49 -04:00
Kit Langton 954a6ca88e refactor(session): effectify SessionSummary service (#20142) 2026-03-31 19:14:45 -04:00
Kit Langton 0c03a3ee10 test: migrate prompt tests to HTTP mock LLM server (#20304) 2026-03-31 19:14:32 -04:00
github-actions[bot] 53330a518f Update VOUCHED list
https://github.com/anomalyco/opencode/issues/20333#issuecomment-4166038038
2026-03-31 22:35:10 +00:00
opencode 892bdebaac release: v1.3.12 2026-03-31 22:35:01 +00:00
Sebastian 18121300f3 upgrade opentui to 0.1.94 (#20357) 2026-03-31 23:54:13 +02:00
github-actions[bot] d6d4446f46 Update VOUCHED list
https://github.com/anomalyco/opencode/issues/20342#issuecomment-4165277636
2026-03-31 20:24:07 +00:00
Major Hayden 26cc924ea2 feat: enable prompt caching and cache token tracking for google-vertex-anthropic (#20266)
Signed-off-by: Major Hayden <major@mhtx.net>
2026-03-31 15:16:14 -05:00
Aiden Cline 4dd866d5c4 fix: rm exclusion of ai-sdk/azure in transform.ts, when we migrated to v6 the ai sdk changed the key for ai-sdk/azure so the exclusion is no longer needed (#20326) 2026-03-31 14:57:15 -05:00
opencode beab4cc2c2 release: v1.3.11 2026-03-31 19:55:41 +00:00
Dax 567a91191a refactor(session): simplify LLM stream by replacing queue with fromAsyncIterable (#20324) 2026-03-31 15:27:51 -04:00
Aiden Cline 434d82bbe2 test: update model test fixture (#20182) 2026-03-31 16:20:01 +00:00
Aiden Cline 2929774acb chore: rm harcoded model definition from codex plugin (#20294) 2026-03-31 11:13:11 -05:00
Adam 6e61a46a84 chore: skip 2 tests 2026-03-31 10:56:06 -05:00
Yuxin Donganddongyuxin 2daf4b805a feat: add a dedicated system prompt for Kimi models (#20259)
Co-authored-by: dongyuxin <dongyuxin@dev.dongyuxin.msh-dev.svc.cluster.local>
2026-03-31 17:44:17 +02:00
opencode-agent[bot] 7342e650c0 chore: update nix node_modules hashes 2026-03-31 15:33:12 +00:00
Adam 8c2e2ecc95 chore: e2e model 2026-03-31 10:14:26 -05:00
Sebastian 25a2b739e6 warn only and ignore plugins without entrypoints, default config via exports (#20284) 2026-03-31 17:14:03 +02:00
Adam 85c16926c4 chore: use paid zen model in e2e 2026-03-31 10:06:44 -05:00
Sebastian 2e78fdec43 ensure pinned plugin versions and do not run package scripts on install (#20248) 2026-03-31 16:59:43 +02:00
Sebastian 1fcb920eb4 upgrade opentui to 0.1.93 (#19950) 2026-03-31 16:50:23 +02:00
opencode b1e89c344b release: v1.3.10 2026-03-31 13:31:37 +00:00
Dax befbedacdc fix(session): subagents not being clickable (#20263) 2026-03-31 08:58:46 -04:00
Frank 2cc738fb17 wip: zen 2026-03-31 00:07:56 -04:00
opencode-agent[bot] 71b20698bb chore: generate 2026-03-31 01:57:41 +00:00
Kit Langton 3df18dcde1 refactor(provider): effectify Provider service (#20160) 2026-03-30 21:56:43 -04:00
Kit Langton a898c2ea3a refactor(storage): effectify Storage service (#20132) 2026-03-31 01:16:02 +00:00
Kit Langton bf777298c8 fix(theme): darken muted text in catppuccin tui themes (#20161) 2026-03-30 21:06:05 -04:00
Luke Parker 93fad99f7f smarter changelog (#20138) 2026-03-31 00:05:46 +00:00
opencode 057848deb8 release: v1.3.9 2026-03-30 23:43:58 +00:00
Luke Parker 1de06452d3 fix(plugin): properly resolve entrypoints without leading dot (#20140) 2026-03-31 09:21:17 +10:00
Frank 58f60629a1 wip: zen 2026-03-30 19:04:42 -04:00
Frank 39a47c9b8c wip: zen 2026-03-30 18:50:09 -04:00
opencode-agent[bot] ea88044f2e chore: generate 2026-03-30 21:49:45 +00:00
Kit Langton e6f6f7aff1 refactor: replace Filesystem util with AppFileSystem service (#20127) 2026-03-30 21:48:28 +00:00
opencode 48e97b47af release: v1.3.8 2026-03-30 21:35:10 +00:00
opencode-agent[bot] fe120e3cbf chore: update nix node_modules hashes 2026-03-30 20:46:10 +00:00
Frank f2dd774660 zen: qwen3.6 plus 2026-03-30 16:43:55 -04:00
Frank e7ff0f17c8 zen: qwen3.6 plus 2026-03-30 16:32:22 -04:00
Kit Langton 2ed756c72c fix(session): restore busy route handling and add regression coverage (#20125) 2026-03-30 20:30:34 +00:00
Frank 054f4be185 zen: add safety identifier 2026-03-30 16:28:40 -04:00
JaaneekandJaaneek e3e1e9af50 fix(Opencode): Bump ai-sdk/xai to 3.0.75 to fix tool calls (#20123)
Co-authored-by: Jaaneek <jankiewiczmilosz@gmail.com>
2026-03-30 15:11:21 -05:00
opencode-agent[bot] c8389cf96d chore: generate 2026-03-30 20:08:09 +00:00
Kit Langton c5442d418d refactor(session): effectify SessionPrompt service (#19483) 2026-03-30 20:06:51 +00:00
Sebastian fa95a61c4e Refactor into plugin loader and do not enforce (#20112) 2026-03-30 20:36:21 +02:00
Aiden Cline 9f3c2bd861 fix: agent value passed to chat.params and chat.headers hooks was not a string (#19996) 2026-03-30 16:32:27 +00:00
Adam c2f78224ae chore(app): cleanup (#20062) 2026-03-30 08:50:42 -05:00
Sebastian 14f9e21d5c pluggable home footer (#20057) 2026-03-30 14:33:01 +02:00
Sebastian 8e4bab5181 update plugin themes when plugin was updated (#20052) 2026-03-30 13:51:07 +02:00
Jack 3c32013eb1 fix: preserve image attachments when selecting slash commands (#19771) 2026-03-30 17:11:34 +08:00
opencode 47d2ab120a release: v1.3.7 2026-03-30 06:06:12 +00:00
Luke Parker 186af2723d make variant modal less annoying (#19998) 2026-03-30 15:42:38 +10:00
Luke Parker 6926fe1c74 fix: stabilize release changelog generation (#19987) 2026-03-30 04:37:02 +00:00
Chris Yang ee018d5c82 docs: rename patch tool to apply_patch and clarify apply_patch behavior (#19979) 2026-03-29 23:01:57 -05:00
opencode-agent[bot] 0465579d6b chore: update nix node_modules hashes 2026-03-30 03:53:11 +00:00
Knut Zuidema 196a03caff fix: discourage _noop tool call during LiteLLM compaction (#18539) 2026-03-29 22:48:17 -05:00
Luke Parker b234370080 feat(windows): add first-class pwsh/powershell support (#16069) 2026-03-30 13:10:01 +10:00
Sebastian 5d2dc8888c theme colors for dialog textarea placeholders (#19939) 2026-03-29 21:37:46 +02:00
Sebastian 0b1018f6dd plugins installs should preserve jsonc comments (#19938) 2026-03-29 21:15:03 +02:00
Aiden Cline afb6abff73 fix: ensure OPENCODE_DISABLE_CLAUDE_CODE_PROMPT is respected for project lvl CLAUDE.md (#19924) 2026-03-29 18:02:29 +00:00
opencode e7f94f9b9a release: v1.3.6 2026-03-29 18:02:20 +00:00
72c77d0e7b fix(session): fix token usage double-counting w/ anthropic & bedrock due to AI SDK v6 upgrade (#19758)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-03-29 12:40:10 -05:00
Ariane Emory 5c15755a10 docs: add question tool to available permissions list (#19922) 2026-03-29 17:35:52 +00:00
Frank 3a4bfeb5b5 wip: zen 2026-03-29 13:06:00 -04:00
Frank 1037c72d99 wip: zen 2026-03-29 12:47:53 -04:00
Kit Langton ba00e9a993 Fix variant dialog filtering (#19917) 2026-03-29 16:43:13 +00:00
Frank 963dad75ef ci: fix 2026-03-29 12:41:52 -04:00
Frank 7e9b721e97 wip: zen 2026-03-29 12:04:20 -04:00
Luke Parker a5b1dc081d test: add regression coverage for sync plugin hooks (#19589) 2026-03-29 03:43:00 +00:00
234 changed files with 79469 additions and 44476 deletions
+4 -1
View File
@@ -11,6 +11,7 @@ adamdotdevin
-agusbasari29 AI PR slop -agusbasari29 AI PR slop
ariane-emory ariane-emory
-atharvau AI review spamming literally every PR -atharvau AI review spamming literally every PR
-borealbytes
-danieljoshuanazareth -danieljoshuanazareth
-danieljoshuanazareth -danieljoshuanazareth
edemaine edemaine
@@ -21,8 +22,10 @@ jayair
kitlangton kitlangton
kommander kommander
-opencode2026 -opencode2026
-opencodeengineer bot that spams issues
r44vc0rp r44vc0rp
rekram1-node rekram1-node
-robinmordasiewicz
-spider-yamet clawdbot/llm psychosis, spam pinging the team -spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr thdxr
-OpenCodeEngineer bot that spams issues -toastythebot
+3 -2
View File
@@ -9,7 +9,8 @@ on:
jobs: jobs:
sync-locales: sync-locales:
if: github.actor != 'opencode-agent[bot]' if: false
#if: github.actor != 'opencode-agent[bot]'
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
permissions: permissions:
contents: write contents: write
@@ -34,7 +35,7 @@ jobs:
- name: Compute changed English docs - name: Compute changed English docs
id: changes id: changes
run: | 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 if [ -z "$FILES" ]; then
echo "has_changes=false" >> "$GITHUB_OUTPUT" echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No English docs changed in push range" echo "No English docs changed in push range"
+3
View File
@@ -100,6 +100,9 @@ jobs:
run: bun --cwd packages/app test:e2e:local run: bun --cwd packages/app test:e2e:local
env: env:
CI: true 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 timeout-minutes: 30
- name: Upload Playwright artifacts - name: Upload Playwright artifacts
+1
View File
@@ -25,6 +25,7 @@ target
# Local dev files # Local dev files
opencode-dev opencode-dev
UPCOMING_CHANGELOG.md
logs/ logs/
*.bun-build *.bun-build
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
+35 -12
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.
``` Before writing any entry you keep, inspect the real diff with
## TUI `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>
+44 -38
View File
@@ -26,7 +26,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.3.5", "version": "1.3.13",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -79,7 +79,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.3.5", "version": "1.3.13",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -113,7 +113,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.3.5", "version": "1.3.13",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -140,7 +140,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.3.5", "version": "1.3.13",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.64", "@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@@ -164,7 +164,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.3.5", "version": "1.3.13",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -188,7 +188,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.3.5", "version": "1.3.13",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -221,7 +221,7 @@
}, },
"packages/desktop-electron": { "packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"version": "1.3.5", "version": "1.3.13",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -252,7 +252,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.3.5", "version": "1.3.13",
"dependencies": { "dependencies": {
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -281,7 +281,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.3.5", "version": "1.3.13",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -297,7 +297,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.3.5", "version": "1.3.13",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -323,7 +323,7 @@
"@ai-sdk/provider-utils": "4.0.21", "@ai-sdk/provider-utils": "4.0.21",
"@ai-sdk/togetherai": "2.0.41", "@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39", "@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", "@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
@@ -338,8 +338,8 @@
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "2.3.3", "@openrouter/ai-sdk-provider": "2.3.3",
"@opentui/core": "0.1.92", "@opentui/core": "0.1.95",
"@opentui/solid": "0.1.92", "@opentui/solid": "0.1.95",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
@@ -378,6 +378,7 @@
"solid-js": "catalog:", "solid-js": "catalog:",
"strip-ansi": "7.1.2", "strip-ansi": "7.1.2",
"tree-sitter-bash": "0.25.0", "tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0", "turndown": "7.2.0",
"ulid": "catalog:", "ulid": "catalog:",
"vscode-jsonrpc": "8.2.1", "vscode-jsonrpc": "8.2.1",
@@ -422,22 +423,22 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.3.5", "version": "1.3.13",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"zod": "catalog:", "zod": "catalog:",
}, },
"devDependencies": { "devDependencies": {
"@opentui/core": "0.1.92", "@opentui/core": "0.1.95",
"@opentui/solid": "0.1.92", "@opentui/solid": "0.1.95",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",
}, },
"peerDependencies": { "peerDependencies": {
"@opentui/core": ">=0.1.92", "@opentui/core": ">=0.1.95",
"@opentui/solid": ">=0.1.92", "@opentui/solid": ">=0.1.95",
}, },
"optionalPeers": [ "optionalPeers": [
"@opentui/core", "@opentui/core",
@@ -456,7 +457,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.3.5", "version": "1.3.13",
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "0.90.10", "@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
@@ -467,7 +468,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.3.5", "version": "1.3.13",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -502,7 +503,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.3.5", "version": "1.3.13",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -549,7 +550,7 @@
}, },
"packages/util": { "packages/util": {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.3.5", "version": "1.3.13",
"dependencies": { "dependencies": {
"zod": "catalog:", "zod": "catalog:",
}, },
@@ -560,7 +561,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.3.5", "version": "1.3.13",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -593,8 +594,9 @@
}, },
}, },
"trustedDependencies": [ "trustedDependencies": [
"electron",
"esbuild", "esbuild",
"tree-sitter-powershell",
"electron",
"web-tree-sitter", "web-tree-sitter",
"tree-sitter-bash", "tree-sitter-bash",
], ],
@@ -610,7 +612,7 @@
}, },
"catalog": { "catalog": {
"@cloudflare/workers-types": "4.20251008.0", "@cloudflare/workers-types": "4.20251008.0",
"@effect/platform-node": "4.0.0-beta.42", "@effect/platform-node": "4.0.0-beta.43",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
@@ -634,7 +636,7 @@
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.42", "effect": "4.0.0-beta.43",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
@@ -717,7 +719,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/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=="], "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
@@ -993,9 +995,9 @@
"@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="], "@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.42", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.42", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.42", "ioredis": "^5.7.0" } }, "sha512-kbdRML2FBa4q8U8rZQcnmLKZ5zN/z1bAA7t5D1/UsBHZqJgnfRgu1CP6kaEfb1Nie6YyaWshxTktZQryjvW/Yg=="], "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.43", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.43", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43", "ioredis": "^5.7.0" } }, "sha512-Uq6E1rjaIpjHauzjwoB2HzAg3battYt2Boy8XO50GoHiWCXKE6WapYZ0/AnaBx5v5qg2sOfqpuiLsUf9ZgxOkA=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.42", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.42" } }, "sha512-PC+lxLsrwob3+nBChAPrQq32olCeyApgXBvs1NrRsoArLViNT76T/68CttuCAksCZj5e1bZ1ZibLPel3vUmx2g=="], "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.43", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43" } }, "sha512-A9q0GEb61pYcQ06Dr6gXj1nKlDI3KHsar1sk3qb1ZY+kVSR64tBAylI8zGon23KY+NPtTUj/sEIToB7jc3Qt5w=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
@@ -1459,21 +1461,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.92", "", { "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.92", "@opentui/core-darwin-x64": "0.1.92", "@opentui/core-linux-arm64": "0.1.92", "@opentui/core-linux-x64": "0.1.92", "@opentui/core-win32-arm64": "0.1.92", "@opentui/core-win32-x64": "0.1.92", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-c+KdYAIH3M8n24RYaor+t7AQtKZ3l84L7xdP7DEaN4xtuYH8W08E6Gi+wUal4g+HSai3HS9irox68yFf0VPAxw=="], "@opentui/core": ["@opentui/core@0.1.95", "", { "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.95", "@opentui/core-darwin-x64": "0.1.95", "@opentui/core-linux-arm64": "0.1.95", "@opentui/core-linux-x64": "0.1.95", "@opentui/core-win32-arm64": "0.1.95", "@opentui/core-win32-x64": "0.1.95", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Ha73I+PPSy6Jk8CTZgdGRHU+nnmrPAs7m6w0k6ge1/kWbcNcZB0lY67sWQMdoa6bSINQMNWg7SjbNCC9B/0exg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.92", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NX/qFRuc7My0pazyOrw9fdTXmU7omXcZzQuHcsaVnwssljaT52UYMrJ7mCKhSo69RhHw0lnGCymTorvz3XBdsA=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.95", "", { "os": "darwin", "cpu": "arm64" }, "sha512-92joqr0ucGaIBCl9uYhe5DwAPbgGMTaCsCeY8Yf3VQ72wjGbOTwnC1TvU5wC6bUmiyqfijCqMyuUnj83teIVVQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.92", "", { "os": "darwin", "cpu": "x64" }, "sha512-Zb4jn33hOf167llINKLniOabQIycs14LPOBZnQ6l4khbeeTPVJdG8gy9PhlAyIQygDKmRTFncVlP0RP+L6C7og=="], "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.95", "", { "os": "darwin", "cpu": "x64" }, "sha512-+TLL3Kp3x7DTWEAkCAYe+RjRhl58QndoeXMstZNS8GQyrjSpUuivzwidzAz0HZK9SbZJfvaxZmXsToAIdI2fag=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.92", "", { "os": "linux", "cpu": "arm64" }, "sha512-4VA1A91OTMPJ3LkAyaxKEZVJsk5jIc3Kz0gV2vip8p2aGLPpYHHpkFZpXP/FyzsnJzoSGftBeA6ya1GKa5bkXg=="], "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.95", "", { "os": "linux", "cpu": "arm64" }, "sha512-dAYeRqh7P8o0xFZleDDR1Abt4gSvCISqw6syOrbH3dl7pMbVdGgzA5stM9jqMgdPUVE7Ngumo17C23ehkGv93A=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.92", "", { "os": "linux", "cpu": "x64" }, "sha512-tr7va8hfKS1uY+TBmulQBoBlwijzJk56K/U/L9/tbHfW7oJctqxPVwEFHIh1HDcOQ3/UhMMWGvMfeG6cFiK8/A=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.95", "", { "os": "linux", "cpu": "x64" }, "sha512-O54TCgK8E7j2NKrDXUOTZqO4sb8JjeAfnhrStxAMMEw4RFCGWx3p3wLesqR16uKfFFJFDyoh2OWZ698tO88EAA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.92", "", { "os": "win32", "cpu": "arm64" }, "sha512-34YM3uPtDjzUVeSnJWIK2J8mxyduzV7f3mYc4Hub0glNpUdM1jjzF2HvvvnrKK5ElzTsIcno3c3lOYT8yvG1Zg=="], "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.95", "", { "os": "win32", "cpu": "arm64" }, "sha512-T1RlZ6U/95eYDN6rUm4SLOVA5LBR7iL3TcBroQhV/883bVczXIBPhriEXQayup5FsAemnQba1BzMNvy6128SUw=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.92", "", { "os": "win32", "cpu": "x64" }, "sha512-uk442kA2Vn0mmJHHqk5sPM+Zai/AN9sgl7egekhoEOUx2VK3gxftKsVlx2YVpCHTvTE/S+vnD2WpQaJk2SNjww=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.95", "", { "os": "win32", "cpu": "x64" }, "sha512-lH2FHO0HSP2xWT+ccoz0BkLYFsMm7e6OYOh63BUHHh5b7ispnzP4aTyxiaLWrfJwdL0M9rp5cLIY32bhBKF2oA=="],
"@opentui/solid": ["@opentui/solid@0.1.92", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.92", "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-0Sx1+6zRpmMJ5oDEY0JS9b9+eGd/Q0fPndNllrQNnp7w2FCjpXmvHdBdq+pFI6kFp01MHq2ZOkUU5zX5/9YMSQ=="], "@opentui/solid": ["@opentui/solid@0.1.95", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.95", "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-iotYCvULgDurLXv3vgOzTLnEOySHFOa/6cEDex76jBt+gkniOEh2cjxxIVt6lkfTsk6UNTk6yCdwNK3nca/j+Q=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -2769,7 +2771,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-beta.42", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-c1UrRP+tLzyHb4Fepl8XBDJlLQLkrcMXrRBba441GQRxMbeQ/aIOSFcBwSda1iMJ5l9F0lYc3Bhe33/whrmavQ=="], "effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -4485,6 +4487,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-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=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
@@ -5285,6 +5289,8 @@
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ai-gateway-provider/@ai-sdk/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=="], "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=="], "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=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-ppK5TVMsmy/7uP1kc6hw3gHMxokD/hBZYt5IGHR3/ok=", "x86_64-linux": "sha256-bjfe8/aD0hvUQQEfaNdmKV/Y3dzpf8oz1OUJdgf61WI=",
"aarch64-linux": "sha256-lrZjanBS8iHJa5TJJHQ9Gaz+lUqNaTgAUuDd6QHu8No=", "aarch64-linux": "sha256-iU9v+ekSCB/qTUG+pOOpSMhPh+0hWnWU5jzDNllEkxU=",
"aarch64-darwin": "sha256-EojkRZQF5NqKPF3Bd/8UIiNngpkBk7uAM8m875bfOUo=", "aarch64-darwin": "sha256-SgNydQLeAjbX0J49f2VKcgKg2Y30pK826R2qQJBMWE4=",
"x86_64-darwin": "sha256-fEO9Hx8yikkvdGj8nC06fy4u/hTGWO6kjENsU/B2OyY=" "x86_64-darwin": "sha256-/rzwNuI9x55qi0UcU7QvPUTupErmkt62T09g1omXkQk="
} }
} }
+3 -2
View File
@@ -25,7 +25,7 @@
"packages/slack" "packages/slack"
], ],
"catalog": { "catalog": {
"@effect/platform-node": "4.0.0-beta.42", "@effect/platform-node": "4.0.0-beta.43",
"@types/bun": "1.3.11", "@types/bun": "1.3.11",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
@@ -45,7 +45,7 @@
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.42", "effect": "4.0.0-beta.43",
"ai": "6.0.138", "ai": "6.0.138",
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
@@ -104,6 +104,7 @@
"protobufjs", "protobufjs",
"tree-sitter", "tree-sitter",
"tree-sitter-bash", "tree-sitter-bash",
"tree-sitter-powershell",
"web-tree-sitter", "web-tree-sitter",
"electron" "electron"
], ],
+33 -19
View File
@@ -312,10 +312,11 @@ export async function openSettings(page: Page) {
return dialog return dialog
} }
export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) { export async function seedProjects(page: Page, input: { directory: string; extra?: string[]; serverUrl?: string }) {
await page.addInitScript( await page.addInitScript(
(args: { directory: string; serverUrl: string; extra: string[] }) => { (args: { directory: string; serverUrl: string; extra: string[] }) => {
const key = "opencode.global.dat:server" const key = "opencode.global.dat:server"
const defaultKey = "opencode.settings.dat:defaultServerUrl"
const raw = localStorage.getItem(key) const raw = localStorage.getItem(key)
const parsed = (() => { const parsed = (() => {
if (!raw) return undefined if (!raw) return undefined
@@ -331,6 +332,7 @@ export async function seedProjects(page: Page, input: { directory: string; extra
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {} const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
const projects = store.projects && typeof store.projects === "object" ? store.projects : {} const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
const nextProjects = { ...(projects as Record<string, unknown>) } const nextProjects = { ...(projects as Record<string, unknown>) }
const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
const add = (origin: string, directory: string) => { const add = (origin: string, directory: string) => {
const current = nextProjects[origin] const current = nextProjects[origin]
@@ -356,17 +358,18 @@ export async function seedProjects(page: Page, input: { directory: string; extra
localStorage.setItem( localStorage.setItem(
key, key,
JSON.stringify({ JSON.stringify({
list, list: nextList,
projects: nextProjects, projects: nextProjects,
lastProject, lastProject,
}), }),
) )
localStorage.setItem(defaultKey, args.serverUrl)
}, },
{ directory: input.directory, serverUrl, extra: input.extra ?? [] }, { directory: input.directory, serverUrl: input.serverUrl ?? serverUrl, extra: input.extra ?? [] },
) )
} }
export async function createTestProject() { export async function createTestProject(input?: { serverUrl?: string }) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-")) const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
const id = `e2e-${path.basename(root)}` const id = `e2e-${path.basename(root)}`
@@ -381,7 +384,7 @@ export async function createTestProject() {
stdio: "ignore", stdio: "ignore",
}) })
return resolveDirectory(root) return resolveDirectory(root, input?.serverUrl)
} }
export async function cleanupTestProject(directory: string) { export async function cleanupTestProject(directory: string) {
@@ -430,22 +433,22 @@ export async function waitSlug(page: Page, skip: string[] = []) {
return next return next
} }
export async function resolveSlug(slug: string) { export async function resolveSlug(slug: string, input?: { serverUrl?: string }) {
const directory = base64Decode(slug) const directory = base64Decode(slug)
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`) if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
const resolved = await resolveDirectory(directory) const resolved = await resolveDirectory(directory, input?.serverUrl)
return { directory: resolved, slug: base64Encode(resolved), raw: slug } return { directory: resolved, slug: base64Encode(resolved), raw: slug }
} }
export async function waitDir(page: Page, directory: string) { export async function waitDir(page: Page, directory: string, input?: { serverUrl?: string }) {
const target = await resolveDirectory(directory) const target = await resolveDirectory(directory, input?.serverUrl)
await expect await expect
.poll( .poll(
async () => { async () => {
await assertHealthy(page, "waitDir") await assertHealthy(page, "waitDir")
const slug = slugFromUrl(page.url()) const slug = slugFromUrl(page.url())
if (!slug) return "" if (!slug) return ""
return resolveSlug(slug) return resolveSlug(slug, input)
.then((item) => item.directory) .then((item) => item.directory)
.catch(() => "") .catch(() => "")
}, },
@@ -455,15 +458,15 @@ export async function waitDir(page: Page, directory: string) {
return { directory: target, slug: base64Encode(target) } return { directory: target, slug: base64Encode(target) }
} }
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) { export async function waitSession(page: Page, input: { directory: string; sessionID?: string; serverUrl?: string }) {
const target = await resolveDirectory(input.directory) const target = await resolveDirectory(input.directory, input.serverUrl)
await expect await expect
.poll( .poll(
async () => { async () => {
await assertHealthy(page, "waitSession") await assertHealthy(page, "waitSession")
const slug = slugFromUrl(page.url()) const slug = slugFromUrl(page.url())
if (!slug) return false if (!slug) return false
const resolved = await resolveSlug(slug).catch(() => undefined) const resolved = await resolveSlug(slug, { serverUrl: input.serverUrl }).catch(() => undefined)
if (!resolved || resolved.directory !== target) return false if (!resolved || resolved.directory !== target) return false
const current = sessionIDFromUrl(page.url()) const current = sessionIDFromUrl(page.url())
if (input.sessionID && current !== input.sessionID) return false if (input.sessionID && current !== input.sessionID) return false
@@ -473,7 +476,7 @@ export async function waitSession(page: Page, input: { directory: string; sessio
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
if (!input.sessionID && state?.sessionID) return false if (!input.sessionID && state?.sessionID) return false
if (state?.dir) { if (state?.dir) {
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "") const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "")
if (dir !== target) return false if (dir !== target) return false
} }
@@ -489,9 +492,9 @@ export async function waitSession(page: Page, input: { directory: string; sessio
return { directory: target, slug: base64Encode(target) } return { directory: target, slug: base64Encode(target) }
} }
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) { export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000, serverUrl?: string) {
const sdk = createSdk(directory) const sdk = createSdk(directory, serverUrl)
const target = await resolveDirectory(directory) const target = await resolveDirectory(directory, serverUrl)
await expect await expect
.poll( .poll(
@@ -501,7 +504,7 @@ export async function waitSessionSaved(directory: string, sessionID: string, tim
.then((x) => x.data) .then((x) => x.data)
.catch(() => undefined) .catch(() => undefined)
if (!data?.directory) return "" if (!data?.directory) return ""
return resolveDirectory(data.directory).catch(() => data.directory) return resolveDirectory(data.directory, serverUrl).catch(() => data.directory)
}, },
{ timeout }, { timeout },
) )
@@ -666,8 +669,9 @@ export async function cleanupSession(input: {
sessionID: string sessionID: string
directory?: string directory?: string
sdk?: ReturnType<typeof createSdk> sdk?: ReturnType<typeof createSdk>
serverUrl?: string
}) { }) {
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined) const sdk = input.sdk ?? (input.directory ? createSdk(input.directory, input.serverUrl) : undefined)
if (!sdk) throw new Error("cleanupSession requires sdk or directory") if (!sdk) throw new Error("cleanupSession requires sdk or directory")
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined) await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
const current = await status(sdk, input.sessionID).catch(() => undefined) const current = await status(sdk, input.sessionID).catch(() => undefined)
@@ -1019,3 +1023,13 @@ export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
await expect(menu).toBeVisible() await expect(menu).toBeVisible()
return menu return menu
} }
export async function assistantText(sdk: ReturnType<typeof createSdk>, sessionID: string) {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
}
+125
View File
@@ -0,0 +1,125 @@
import { spawn } from "node:child_process"
import fs from "node:fs/promises"
import net from "node:net"
import os from "node:os"
import path from "node:path"
import { fileURLToPath } from "node:url"
type Handle = {
url: string
stop: () => Promise<void>
}
function freePort() {
return new Promise<number>((resolve, reject) => {
const server = net.createServer()
server.once("error", reject)
server.listen(0, () => {
const address = server.address()
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to acquire a free port")))
return
}
server.close((err) => {
if (err) reject(err)
else resolve(address.port)
})
})
})
}
async function waitForHealth(url: string, probe = "/global/health") {
const end = Date.now() + 120_000
let last = ""
while (Date.now() < end) {
try {
const res = await fetch(`${url}${probe}`)
if (res.ok) return
last = `status ${res.status}`
} catch (err) {
last = err instanceof Error ? err.message : String(err)
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`)
}
const LOG_CAP = 100
function cap(input: string[]) {
if (input.length > LOG_CAP) input.splice(0, input.length - LOG_CAP)
}
function tail(input: string[]) {
return input.slice(-40).join("")
}
export async function startBackend(label: string): Promise<Handle> {
const port = await freePort()
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`))
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
const repoDir = path.resolve(appDir, "../..")
const opencodeDir = path.join(repoDir, "packages", "opencode")
const env = {
...process.env,
OPENCODE_DISABLE_SHARE: process.env.OPENCODE_DISABLE_SHARE ?? "true",
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
OPENCODE_TEST_HOME: path.join(sandbox, "home"),
XDG_DATA_HOME: path.join(sandbox, "share"),
XDG_CACHE_HOME: path.join(sandbox, "cache"),
XDG_CONFIG_HOME: path.join(sandbox, "config"),
XDG_STATE_HOME: path.join(sandbox, "state"),
OPENCODE_CLIENT: "app",
OPENCODE_STRICT_CONFIG_DEPS: "true",
} satisfies Record<string, string | undefined>
const out: string[] = []
const err: string[] = []
const proc = spawn(
"bun",
["run", "--conditions=browser", "./src/index.ts", "serve", "--port", String(port), "--hostname", "127.0.0.1"],
{
cwd: opencodeDir,
env,
stdio: ["ignore", "pipe", "pipe"],
},
)
proc.stdout?.on("data", (chunk) => {
out.push(String(chunk))
cap(out)
})
proc.stderr?.on("data", (chunk) => {
err.push(String(chunk))
cap(err)
})
const url = `http://127.0.0.1:${port}`
try {
await waitForHealth(url)
} catch (error) {
proc.kill("SIGTERM")
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
throw new Error(
[
`Failed to start isolated e2e backend for ${label}`,
error instanceof Error ? error.message : String(error),
tail(out),
tail(err),
]
.filter(Boolean)
.join("\n"),
)
}
return {
url,
async stop() {
if (proc.exitCode === null) {
proc.kill("SIGTERM")
await new Promise((resolve) => proc.once("exit", () => resolve(undefined))).catch(() => undefined)
}
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
},
}
}
+153 -21
View File
@@ -1,5 +1,9 @@
import { test as base, expect, type Page } from "@playwright/test" import { test as base, expect, type Page } from "@playwright/test"
import { ManagedRuntime } from "effect"
import type { E2EWindow } from "../src/testing/terminal" import type { E2EWindow } from "../src/testing/terminal"
import type { Item, Reply, Usage } from "../../opencode/test/lib/llm-server"
import { TestLLMServer } from "../../opencode/test/lib/llm-server"
import { startBackend } from "./backend"
import { import {
healthPhase, healthPhase,
cleanupSession, cleanupSession,
@@ -13,29 +17,126 @@ import {
} from "./actions" } from "./actions"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
type LLMFixture = {
url: string
push: (...input: (Item | Reply)[]) => Promise<void>
pushMatch: (
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
...input: (Item | Reply)[]
) => Promise<void>
textMatch: (
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
value: string,
opts?: { usage?: Usage },
) => Promise<void>
toolMatch: (
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
name: string,
input: unknown,
) => Promise<void>
text: (value: string, opts?: { usage?: Usage }) => Promise<void>
tool: (name: string, input: unknown) => Promise<void>
toolHang: (name: string, input: unknown) => Promise<void>
reason: (value: string, opts?: { text?: string; usage?: Usage }) => Promise<void>
fail: (message?: unknown) => Promise<void>
error: (status: number, body: unknown) => Promise<void>
hang: () => Promise<void>
hold: (value: string, wait: PromiseLike<unknown>) => Promise<void>
hits: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
calls: () => Promise<number>
wait: (count: number) => Promise<void>
inputs: () => Promise<Record<string, unknown>[]>
pending: () => Promise<number>
}
export const settingsKey = "settings.v3" export const settingsKey = "settings.v3"
type TestFixtures = { const seedModel = (() => {
sdk: ReturnType<typeof createSdk> const [providerID = "opencode", modelID = "big-pickle"] = (
gotoSession: (sessionID?: string) => Promise<void> process.env.OPENCODE_E2E_MODEL ?? "opencode/big-pickle"
withProject: <T>( ).split("/")
callback: (project: { return {
providerID: providerID || "opencode",
modelID: modelID || "big-pickle",
}
})()
type ProjectHandle = {
directory: string directory: string
slug: string slug: string
gotoSession: (sessionID?: string) => Promise<void> gotoSession: (sessionID?: string) => Promise<void>
trackSession: (sessionID: string, directory?: string) => void trackSession: (sessionID: string, directory?: string) => void
trackDirectory: (directory: string) => void trackDirectory: (directory: string) => void
}) => Promise<T>, sdk: ReturnType<typeof createSdk>
options?: { extra?: string[] }, }
) => Promise<T>
type ProjectOptions = {
extra?: string[]
model?: { providerID: string; modelID: string }
setup?: (directory: string) => Promise<void>
beforeGoto?: (project: { directory: string; sdk: ReturnType<typeof createSdk> }) => Promise<void>
}
type TestFixtures = {
llm: LLMFixture
sdk: ReturnType<typeof createSdk>
gotoSession: (sessionID?: string) => Promise<void>
withProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
withBackendProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
} }
type WorkerFixtures = { type WorkerFixtures = {
backend: {
url: string
sdk: (directory?: string) => ReturnType<typeof createSdk>
}
directory: string directory: string
slug: string slug: string
} }
export const test = base.extend<TestFixtures, WorkerFixtures>({ export const test = base.extend<TestFixtures, WorkerFixtures>({
backend: [
async ({}, use, workerInfo) => {
const handle = await startBackend(`w${workerInfo.workerIndex}`)
try {
await use({
url: handle.url,
sdk: (directory?: string) => createSdk(directory, handle.url),
})
} finally {
await handle.stop()
}
},
{ scope: "worker" },
],
llm: async ({}, use) => {
const rt = ManagedRuntime.make(TestLLMServer.layer)
try {
const svc = await rt.runPromise(TestLLMServer.asEffect())
await use({
url: svc.url,
push: (...input) => rt.runPromise(svc.push(...input)),
pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)),
textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)),
toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, input)),
text: (value, opts) => rt.runPromise(svc.text(value, opts)),
tool: (name, input) => rt.runPromise(svc.tool(name, input)),
toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
fail: (message) => rt.runPromise(svc.fail(message)),
error: (status, body) => rt.runPromise(svc.error(status, body)),
hang: () => rt.runPromise(svc.hang),
hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
hits: () => rt.runPromise(svc.hits),
calls: () => rt.runPromise(svc.calls),
wait: (count) => rt.runPromise(svc.wait(count)),
inputs: () => rt.runPromise(svc.inputs),
pending: () => rt.runPromise(svc.pending),
})
} finally {
await rt.dispose()
}
},
page: async ({ page }, use) => { page: async ({ page }, use) => {
let boundary: string | undefined let boundary: string | undefined
setHealthPhase(page, "test") setHealthPhase(page, "test")
@@ -85,15 +186,39 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
await use(gotoSession) await use(gotoSession)
}, },
withProject: async ({ page }, use) => { withProject: async ({ page }, use) => {
await use(async (callback, options) => { await use((callback, options) => runProject(page, callback, options))
const root = await createTestProject() },
withBackendProject: async ({ page, backend }, use) => {
await use((callback, options) =>
runProject(page, callback, { ...options, serverUrl: backend.url, sdk: backend.sdk }),
)
},
})
async function runProject<T>(
page: Page,
callback: (project: ProjectHandle) => Promise<T>,
options?: ProjectOptions & {
serverUrl?: string
sdk?: (directory?: string) => ReturnType<typeof createSdk>
},
) {
const url = options?.serverUrl
const root = await createTestProject(url ? { serverUrl: url } : undefined)
const sdk = options?.sdk?.(root) ?? createSdk(root, url)
const sessions = new Map<string, string>() const sessions = new Map<string, string>()
const dirs = new Set<string>() const dirs = new Set<string>()
await seedStorage(page, { directory: root, extra: options?.extra }) await options?.setup?.(root)
await seedStorage(page, {
directory: root,
extra: options?.extra,
model: options?.model,
serverUrl: url,
})
const gotoSession = async (sessionID?: string) => { const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(root, sessionID)) await page.goto(sessionPath(root, sessionID))
await waitSession(page, { directory: root, sessionID }) await waitSession(page, { directory: root, sessionID, serverUrl: url })
const current = sessionIDFromUrl(page.url()) const current = sessionIDFromUrl(page.url())
if (current) trackSession(current) if (current) trackSession(current)
} }
@@ -107,25 +232,32 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
} }
try { try {
await options?.beforeGoto?.({ directory: root, sdk })
await gotoSession() await gotoSession()
const slug = await waitSlug(page) const slug = await waitSlug(page)
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory }) return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory, sdk })
} finally { } finally {
setHealthPhase(page, "cleanup") setHealthPhase(page, "cleanup")
await Promise.allSettled( await Promise.allSettled(
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })), Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory, serverUrl: url })),
) )
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory))) await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
await cleanupTestProject(root) await cleanupTestProject(root)
setHealthPhase(page, "test") setHealthPhase(page, "test")
} }
}) }
},
})
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) { async function seedStorage(
page: Page,
input: {
directory: string
extra?: string[]
model?: { providerID: string; modelID: string }
serverUrl?: string
},
) {
await seedProjects(page, input) await seedProjects(page, input)
await page.addInitScript(() => { await page.addInitScript((model: { providerID: string; modelID: string }) => {
const win = window as E2EWindow const win = window as E2EWindow
win.__opencode_e2e = { win.__opencode_e2e = {
...win.__opencode_e2e, ...win.__opencode_e2e,
@@ -143,12 +275,12 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin
localStorage.setItem( localStorage.setItem(
"opencode.global.dat:model", "opencode.global.dat:model",
JSON.stringify({ JSON.stringify({
recent: [{ providerID: "opencode", modelID: "big-pickle" }], recent: [model],
user: [], user: [],
variant: {}, variant: {},
}), }),
) )
}) }, input.model ?? seedModel)
} }
export { expect } export { expect }
+46
View File
@@ -0,0 +1,46 @@
import { createSdk } from "../utils"
export const openaiModel = { providerID: "openai", modelID: "gpt-5.3-chat-latest" }
type Hit = { body: Record<string, unknown> }
export function bodyText(hit: Hit) {
return JSON.stringify(hit.body)
}
export function titleMatch(hit: Hit) {
return bodyText(hit).includes("Generate a title for this conversation")
}
export function promptMatch(token: string) {
return (hit: Hit) => bodyText(hit).includes(token)
}
export async function withMockOpenAI<T>(input: { serverUrl: string; llmUrl: string; fn: () => Promise<T> }) {
const sdk = createSdk(undefined, input.serverUrl)
const prev = await sdk.global.config.get().then((res) => res.data ?? {})
try {
await sdk.global.config.update({
config: {
...prev,
model: `${openaiModel.providerID}/${openaiModel.modelID}`,
enabled_providers: ["openai"],
provider: {
...prev.provider,
openai: {
...prev.provider?.openai,
options: {
...prev.provider?.openai?.options,
apiKey: "test-key",
baseURL: input.llmUrl,
},
},
},
},
})
return await input.fn()
} finally {
await sdk.global.config.update({ config: prev })
}
}
+26 -21
View File
@@ -1,47 +1,52 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions" import { assistantText, sessionIDFromUrl, withSession } from "../actions"
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim() const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
// Regression test for Issue #12453: the synchronous POST /message endpoint holds // Regression test for Issue #12453: the synchronous POST /message endpoint holds
// the connection open while the agent works, causing "Failed to fetch" over // the connection open while the agent works, causing "Failed to fetch" over
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately. // VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => { test("prompt succeeds when sync message endpoint is unreachable", async ({
page,
llm,
backend,
withBackendProject,
}) => {
test.setTimeout(120_000) test.setTimeout(120_000)
// Simulate Tailscale/VPN killing the long-lived sync connection // Simulate Tailscale/VPN killing the long-lived sync connection
await page.route("**/session/*/message", (route) => route.abort("connectionfailed")) await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
await gotoSession() await withMockOpenAI({
serverUrl: backend.url,
llmUrl: llm.url,
fn: async () => {
const token = `E2E_ASYNC_${Date.now()}` const token = `E2E_ASYNC_${Date.now()}`
await llm.textMatch(titleMatch, "E2E Title")
await llm.textMatch(promptMatch(token), token)
await withBackendProject(
async (project) => {
await page.locator(promptSelector).click() await page.locator(promptSelector).click()
await page.keyboard.type(`Reply with exactly: ${token}`) await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())! const sessionID = sessionIDFromUrl(page.url())!
project.trackSession(sessionID)
try { await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
// Agent response arrives via SSE despite sync endpoint being dead
await expect await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token)
.poll( },
async () => { {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? []) model: openaiModel,
return messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
}, },
{ timeout: 90_000 },
) )
.toContain(token) },
} finally { })
await cleanupSession({ sdk, sessionID })
}
}) })
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => { test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
+63 -30
View File
@@ -1,8 +1,9 @@
import type { ToolPart } from "@opencode-ai/sdk/v2/client" import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import type { Page } from "@playwright/test" import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { withSession } from "../actions" import { assistantText, sessionIDFromUrl } from "../actions"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim() const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
@@ -43,20 +44,13 @@ async function wait(page: Page, value: string) {
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value) await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
} }
async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) { async function reply(
sdk: { session: { messages: Parameters<typeof assistantText>[0]["session"] } },
sessionID: string,
token: string,
) {
await expect await expect
.poll( .poll(() => assistantText(sdk as Parameters<typeof assistantText>[0], sessionID), { timeout: 90_000 })
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((item) => item.info.role === "assistant")
.flatMap((item) => item.parts)
.filter((item) => item.type === "text")
.map((item) => item.text)
.join("\n")
},
{ timeout: 90_000 },
)
.toContain(token) .toContain(token)
} }
@@ -79,36 +73,52 @@ async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string,
.toContain(token) .toContain(token)
} }
test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => { test("prompt history restores unsent draft with arrow navigation", async ({
page,
llm,
backend,
withBackendProject,
}) => {
test.setTimeout(120_000) test.setTimeout(120_000)
await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => { await withMockOpenAI({
await gotoSession(session.id) serverUrl: backend.url,
llmUrl: llm.url,
const prompt = page.locator(promptSelector) fn: async () => {
const firstToken = `E2E_HISTORY_ONE_${Date.now()}` const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
const secondToken = `E2E_HISTORY_TWO_${Date.now()}` const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
const first = `Reply with exactly: ${firstToken}` const first = `Reply with exactly: ${firstToken}`
const second = `Reply with exactly: ${secondToken}` const second = `Reply with exactly: ${secondToken}`
const draft = `draft ${Date.now()}` const draft = `draft ${Date.now()}`
await llm.textMatch(titleMatch, "E2E Title")
await llm.textMatch(promptMatch(firstToken), firstToken)
await llm.textMatch(promptMatch(secondToken), secondToken)
await withBackendProject(
async (project) => {
const prompt = page.locator(promptSelector)
await prompt.click() await prompt.click()
await page.keyboard.type(first) await page.keyboard.type(first)
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await wait(page, "") await wait(page, "")
await reply(sdk, session.id, firstToken)
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())!
project.trackSession(sessionID)
await reply(project.sdk, sessionID, firstToken)
await prompt.click() await prompt.click()
await page.keyboard.type(second) await page.keyboard.type(second)
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await wait(page, "") await wait(page, "")
await reply(sdk, session.id, secondToken) await reply(project.sdk, sessionID, secondToken)
await prompt.click() await prompt.click()
await page.keyboard.type(draft) await page.keyboard.type(draft)
await wait(page, draft) await wait(page, draft)
// Clear the draft before navigating history (ArrowUp only works when prompt is empty)
await prompt.fill("") await prompt.fill("")
await wait(page, "") await wait(page, "")
@@ -123,16 +133,22 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
await page.keyboard.press("ArrowDown") await page.keyboard.press("ArrowDown")
await wait(page, "") await wait(page, "")
},
{
model: openaiModel,
},
)
},
}) })
}) })
test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => { test("shell history stays separate from normal prompt history", async ({ page, llm, backend, withBackendProject }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => { await withMockOpenAI({
await gotoSession(session.id) serverUrl: backend.url,
llmUrl: llm.url,
const prompt = page.locator(promptSelector) fn: async () => {
const firstToken = `E2E_SHELL_ONE_${Date.now()}` const firstToken = `E2E_SHELL_ONE_${Date.now()}`
const secondToken = `E2E_SHELL_TWO_${Date.now()}` const secondToken = `E2E_SHELL_TWO_${Date.now()}`
const normalToken = `E2E_NORMAL_${Date.now()}` const normalToken = `E2E_NORMAL_${Date.now()}`
@@ -140,19 +156,30 @@ test("shell history stays separate from normal prompt history", async ({ page, s
const second = `echo ${secondToken}` const second = `echo ${secondToken}`
const normal = `Reply with exactly: ${normalToken}` const normal = `Reply with exactly: ${normalToken}`
await llm.textMatch(titleMatch, "E2E Title")
await llm.textMatch(promptMatch(normalToken), normalToken)
await withBackendProject(
async (project) => {
const prompt = page.locator(promptSelector)
await prompt.click() await prompt.click()
await page.keyboard.type("!") await page.keyboard.type("!")
await page.keyboard.type(first) await page.keyboard.type(first)
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await wait(page, "") await wait(page, "")
await shell(sdk, session.id, first, firstToken)
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())!
project.trackSession(sessionID)
await shell(project.sdk, sessionID, first, firstToken)
await prompt.click() await prompt.click()
await page.keyboard.type("!") await page.keyboard.type("!")
await page.keyboard.type(second) await page.keyboard.type(second)
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await wait(page, "") await wait(page, "")
await shell(sdk, session.id, second, secondToken) await shell(project.sdk, sessionID, second, secondToken)
await prompt.click() await prompt.click()
await page.keyboard.type("!") await page.keyboard.type("!")
@@ -175,10 +202,16 @@ test("shell history stays separate from normal prompt history", async ({ page, s
await page.keyboard.type(normal) await page.keyboard.type(normal)
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await wait(page, "") await wait(page, "")
await reply(sdk, session.id, normalToken) await reply(project.sdk, sessionID, normalToken)
await prompt.click() await prompt.click()
await page.keyboard.press("ArrowUp") await page.keyboard.press("ArrowUp")
await wait(page, normal) await wait(page, normal)
},
{
model: openaiModel,
},
)
},
}) })
}) })
+3 -5
View File
@@ -2,7 +2,6 @@ import type { ToolPart } from "@opencode-ai/sdk/v2/client"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { sessionIDFromUrl } from "../actions" import { sessionIDFromUrl } from "../actions"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { createSdk } from "../utils"
const isBash = (part: unknown): part is ToolPart => { const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false if (!part || typeof part !== "object") return false
@@ -11,13 +10,12 @@ const isBash = (part: unknown): part is ToolPart => {
return "state" in part return "state" in part
} }
test("shell mode runs a command in the project directory", async ({ page, withProject }) => { test("shell mode runs a command in the project directory", async ({ page, withBackendProject }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
await withProject(async ({ directory, gotoSession, trackSession }) => { await withBackendProject(async ({ directory, gotoSession, trackSession, sdk }) => {
const sdk = createSdk(directory)
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
const cmd = process.platform === "win32" ? "dir" : "ls" const cmd = process.platform === "win32" ? "dir" : "command ls"
await gotoSession() await gotoSession()
await prompt.click() await prompt.click()
@@ -22,14 +22,15 @@ async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
.toBeGreaterThan(0) .toBeGreaterThan(0)
} }
test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => { test("/share and /unshare update session share state", async ({ page, withBackendProject }) => {
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).") test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => { await withBackendProject(async (project) => {
await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
await seed(sdk, session.id) await seed(project.sdk, session.id)
await gotoSession(session.id) await project.gotoSession(session.id)
await prompt.click() await prompt.click()
await page.keyboard.type("/share") await page.keyboard.type("/share")
@@ -39,7 +40,7 @@ test("/share and /unshare update session share state", async ({ page, sdk, gotoS
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined return data?.share?.url || undefined
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
@@ -54,11 +55,12 @@ test("/share and /unshare update session share state", async ({ page, sdk, gotoS
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined return data?.share?.url || undefined
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
) )
.toBeUndefined() .toBeUndefined()
}) })
})
}) })
+22 -19
View File
@@ -1,8 +1,9 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions" import { assistantText, sessionIDFromUrl } from "../actions"
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => { test("can send a prompt and receive a reply", async ({ page, llm, backend, withBackendProject }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
const pageErrors: string[] = [] const pageErrors: string[] = []
@@ -11,10 +12,18 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
} }
page.on("pageerror", onPageError) page.on("pageerror", onPageError)
await gotoSession() try {
await withMockOpenAI({
serverUrl: backend.url,
llmUrl: llm.url,
fn: async () => {
const token = `E2E_OK_${Date.now()}` const token = `E2E_OK_${Date.now()}`
await llm.textMatch(titleMatch, "E2E Title")
await llm.textMatch(promptMatch(token), token)
await withBackendProject(
async (project) => {
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
await prompt.click() await prompt.click()
await page.keyboard.type(`Reply with exactly: ${token}`) await page.keyboard.type(`Reply with exactly: ${token}`)
@@ -27,26 +36,20 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`) if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
return id return id
})() })()
project.trackSession(sessionID)
try { await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
await expect
.poll( await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
async () => { },
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? []) {
return messages model: openaiModel,
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
}, },
{ timeout: 90_000 },
) )
},
.toContain(token) })
} finally { } finally {
page.off("pageerror", onPageError) page.off("pageerror", onPageError)
await cleanupSession({ sdk, sessionID })
} }
if (pageErrors.length > 0) { if (pageErrors.length > 0) {
@@ -1,7 +1,7 @@
import { seedSessionTask, withSession } from "../actions" import { seedSessionTask, withSession } from "../actions"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => { test("task tool child-session link does not trigger stale show errors", async ({ page, withBackendProject }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
const errs: string[] = [] const errs: string[] = []
@@ -10,12 +10,15 @@ test("task tool child-session link does not trigger stale show errors", async ({
} }
page.on("pageerror", onError) page.on("pageerror", onError)
await withBackendProject(async ({ gotoSession, trackSession, sdk }) => {
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => { await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
trackSession(session.id)
const child = await seedSessionTask(sdk, { const child = await seedSessionTask(sdk, {
sessionID: session.id, sessionID: session.id,
description: "Open child session", description: "Open child session",
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.", prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
}) })
trackSession(child.sessionID)
try { try {
await gotoSession(session.id) await gotoSession(session.id)
@@ -34,4 +37,5 @@ test("task tool child-session link does not trigger stale show errors", async ({
page.off("pageerror", onError) page.off("pageerror", onError)
} }
}) })
})
}) })
@@ -13,6 +13,7 @@ import {
sessionComposerDockSelector, sessionComposerDockSelector,
sessionTodoToggleButtonSelector, sessionTodoToggleButtonSelector,
} from "../selectors" } from "../selectors"
import { modKey } from "../utils"
type Sdk = Parameters<typeof clearSessionDockSeed>[0] type Sdk = Parameters<typeof clearSessionDockSeed>[0]
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" } type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
@@ -255,9 +256,10 @@ async function withMockPermission<T>(
} }
} }
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => { test("default dock shows prompt input", async ({ page, withBackendProject }) => {
await withDockSession(sdk, "e2e composer dock default", async (session) => { await withBackendProject(async (project) => {
await gotoSession(session.id) await withDockSession(project.sdk, "e2e composer dock default", async (session) => {
await project.gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible() await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toBeVisible() await expect(page.locator(promptSelector)).toBeVisible()
@@ -267,9 +269,11 @@ test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
await page.locator(promptSelector).click() await page.locator(promptSelector).click()
await expect(page.locator(promptSelector)).toBeFocused() await expect(page.locator(promptSelector)).toBeFocused()
}) })
})
}) })
test("auto-accept toggle works before first submit", async ({ page, gotoSession }) => { test("auto-accept toggle works before first submit", async ({ page, withBackendProject }) => {
await withBackendProject(async ({ gotoSession }) => {
await gotoSession() await gotoSession()
const button = page.locator('[data-action="prompt-permissions"]').first() const button = page.locator('[data-action="prompt-permissions"]').first()
@@ -278,14 +282,16 @@ test("auto-accept toggle works before first submit", async ({ page, gotoSession
await setAutoAccept(page, true) await setAutoAccept(page, true)
await setAutoAccept(page, false) await setAutoAccept(page, false)
})
}) })
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => { test("blocked question flow unblocks after submit", async ({ page, withBackendProject }) => {
await withDockSession(sdk, "e2e composer dock question", async (session) => { await withBackendProject(async (project) => {
await withDockSeed(sdk, session.id, async () => { await withDockSession(project.sdk, "e2e composer dock question", async (session) => {
await gotoSession(session.id) await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
await seedSessionQuestion(sdk, { await seedSessionQuestion(project.sdk, {
sessionID: session.id, sessionID: session.id,
questions: [ questions: [
{ {
@@ -308,11 +314,84 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess
await expectQuestionOpen(page) await expectQuestionOpen(page)
}) })
}) })
})
}) })
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => { test("blocked question flow supports keyboard shortcuts", async ({ page, withBackendProject }) => {
await withDockSession(sdk, "e2e composer dock permission once", async (session) => { await withBackendProject(async (project) => {
await gotoSession(session.id) await withDockSession(project.sdk, "e2e composer dock question keyboard", async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
await seedSessionQuestion(project.sdk, {
sessionID: session.id,
questions: [
{
header: "Need input",
question: "Pick one option",
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
],
})
const dock = page.locator(questionDockSelector)
const first = dock.locator('[data-slot="question-option"]').first()
const second = dock.locator('[data-slot="question-option"]').nth(1)
await expectQuestionBlocked(page)
await expect(first).toBeFocused()
await page.keyboard.press("ArrowDown")
await expect(second).toBeFocused()
await page.keyboard.press("Space")
await page.keyboard.press(`${modKey}+Enter`)
await expectQuestionOpen(page)
})
})
})
})
test("blocked question flow supports escape dismiss", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock question escape", async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
await seedSessionQuestion(project.sdk, {
sessionID: session.id,
questions: [
{
header: "Need input",
question: "Pick one option",
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
],
})
const dock = page.locator(questionDockSelector)
const first = dock.locator('[data-slot="question-option"]').first()
await expectQuestionBlocked(page)
await expect(first).toBeFocused()
await page.keyboard.press("Escape")
await expectQuestionOpen(page)
})
})
})
})
test("blocked permission flow supports allow once", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock permission once", async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false) await setAutoAccept(page, false)
await withMockPermission( await withMockPermission(
page, page,
@@ -335,11 +414,13 @@ test("blocked permission flow supports allow once", async ({ page, sdk, gotoSess
}, },
) )
}) })
})
}) })
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => { test("blocked permission flow supports reject", async ({ page, withBackendProject }) => {
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => { await withBackendProject(async (project) => {
await gotoSession(session.id) await withDockSession(project.sdk, "e2e composer dock permission reject", async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false) await setAutoAccept(page, false)
await withMockPermission( await withMockPermission(
page, page,
@@ -361,11 +442,13 @@ test("blocked permission flow supports reject", async ({ page, sdk, gotoSession
}, },
) )
}) })
})
}) })
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => { test("blocked permission flow supports allow always", async ({ page, withBackendProject }) => {
await withDockSession(sdk, "e2e composer dock permission always", async (session) => { await withBackendProject(async (project) => {
await gotoSession(session.id) await withDockSession(project.sdk, "e2e composer dock permission always", async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false) await setAutoAccept(page, false)
await withMockPermission( await withMockPermission(
page, page,
@@ -388,17 +471,18 @@ test("blocked permission flow supports allow always", async ({ page, sdk, gotoSe
}, },
) )
}) })
})
}) })
test("child session question request blocks parent dock and unblocks after submit", async ({ test("child session question request blocks parent dock and unblocks after submit", async ({
page, page,
sdk, withBackendProject,
gotoSession,
}) => { }) => {
await withDockSession(sdk, "e2e composer dock child question parent", async (session) => { await withBackendProject(async (project) => {
await gotoSession(session.id) await withDockSession(project.sdk, "e2e composer dock child question parent", async (session) => {
await project.gotoSession(session.id)
const child = await sdk.session const child = await project.sdk.session
.create({ .create({
title: "e2e composer dock child question", title: "e2e composer dock child question",
parentID: session.id, parentID: session.id,
@@ -407,8 +491,8 @@ test("child session question request blocks parent dock and unblocks after submi
if (!child?.id) throw new Error("Child session create did not return an id") if (!child?.id) throw new Error("Child session create did not return an id")
try { try {
await withDockSeed(sdk, child.id, async () => { await withDockSeed(project.sdk, child.id, async () => {
await seedSessionQuestion(sdk, { await seedSessionQuestion(project.sdk, {
sessionID: child.id, sessionID: child.id,
questions: [ questions: [
{ {
@@ -431,21 +515,22 @@ test("child session question request blocks parent dock and unblocks after submi
await expectQuestionOpen(page) await expectQuestionOpen(page)
}) })
} finally { } finally {
await cleanupSession({ sdk, sessionID: child.id }) await cleanupSession({ sdk: project.sdk, sessionID: child.id })
} }
}) })
})
}) })
test("child session permission request blocks parent dock and supports allow once", async ({ test("child session permission request blocks parent dock and supports allow once", async ({
page, page,
sdk, withBackendProject,
gotoSession,
}) => { }) => {
await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => { await withBackendProject(async (project) => {
await gotoSession(session.id) await withDockSession(project.sdk, "e2e composer dock child permission parent", async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false) await setAutoAccept(page, false)
const child = await sdk.session const child = await project.sdk.session
.create({ .create({
title: "e2e composer dock child permission", title: "e2e composer dock child permission",
parentID: session.id, parentID: session.id,
@@ -476,15 +561,17 @@ test("child session permission request blocks parent dock and supports allow onc
}, },
) )
} finally { } finally {
await cleanupSession({ sdk, sessionID: child.id }) await cleanupSession({ sdk: project.sdk, sessionID: child.id })
} }
}) })
})
}) })
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => { test("todo dock transitions and collapse behavior", async ({ page, withBackendProject }) => {
await withDockSession(sdk, "e2e composer dock todo", async (session) => { await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock todo", async (session) => {
const dock = await todoDock(page, session.id) const dock = await todoDock(page, session.id)
await gotoSession(session.id) await project.gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible() await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
try { try {
@@ -509,14 +596,16 @@ test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSess
await dock.clear() await dock.clear()
} }
}) })
})
}) })
test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => { test("keyboard focus stays off prompt while blocked", async ({ page, withBackendProject }) => {
await withDockSession(sdk, "e2e composer dock keyboard", async (session) => { await withBackendProject(async (project) => {
await withDockSeed(sdk, session.id, async () => { await withDockSession(project.sdk, "e2e composer dock keyboard", async (session) => {
await gotoSession(session.id) await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
await seedSessionQuestion(sdk, { await seedSessionQuestion(project.sdk, {
sessionID: session.id, sessionID: session.id,
questions: [ questions: [
{ {
@@ -534,4 +623,5 @@ test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSe
await expect(page.locator(promptSelector)).toHaveCount(0) await expect(page.locator(promptSelector)).toHaveCount(0)
}) })
}) })
})
}) })
@@ -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("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
test.skip(true, "Flaky in CI for now.")
test.setTimeout(180_000) test.setTimeout(180_000)
const tag = `review-comment-${Date.now()}` 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("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
test.skip(true, "Flaky in CI for now.")
test.setTimeout(180_000) test.setTimeout(180_000)
const tag = `review-file-comment-${Date.now()}` const tag = `review-file-comment-${Date.now()}`
@@ -49,13 +49,13 @@ async function seedConversation(input: {
return { prompt, userMessageID } return { prompt, userMessageID }
} }
test("slash undo sets revert and restores prior prompt", async ({ page, withProject }) => { test("slash undo sets revert and restores prior prompt", async ({ page, withBackendProject }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
const token = `undo_${Date.now()}` const token = `undo_${Date.now()}`
await withProject(async (project) => { await withBackendProject(async (project) => {
const sdk = createSdk(project.directory) const sdk = project.sdk
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => { await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
@@ -81,13 +81,13 @@ test("slash undo sets revert and restores prior prompt", async ({ page, withProj
}) })
}) })
test("slash redo clears revert and restores latest state", async ({ page, withProject }) => { test("slash redo clears revert and restores latest state", async ({ page, withBackendProject }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
const token = `redo_${Date.now()}` const token = `redo_${Date.now()}`
await withProject(async (project) => { await withBackendProject(async (project) => {
const sdk = createSdk(project.directory) const sdk = project.sdk
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => { await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
@@ -128,14 +128,14 @@ test("slash redo clears revert and restores latest state", async ({ page, withPr
}) })
}) })
test("slash undo/redo traverses multi-step revert stack", async ({ page, withProject }) => { test("slash undo/redo traverses multi-step revert stack", async ({ page, withBackendProject }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
const firstToken = `undo_redo_first_${Date.now()}` const firstToken = `undo_redo_first_${Date.now()}`
const secondToken = `undo_redo_second_${Date.now()}` const secondToken = `undo_redo_second_${Date.now()}`
await withProject(async (project) => { await withBackendProject(async (project) => {
const sdk = createSdk(project.directory) const sdk = project.sdk
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => { await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
+29 -21
View File
@@ -31,14 +31,15 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
.toBeGreaterThan(0) .toBeGreaterThan(0)
} }
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => { test("session can be renamed via header menu", async ({ page, withBackendProject }) => {
const stamp = Date.now() const stamp = Date.now()
const originalTitle = `e2e rename test ${stamp}` const originalTitle = `e2e rename test ${stamp}`
const renamedTitle = `e2e renamed ${stamp}` const renamedTitle = `e2e renamed ${stamp}`
await withSession(sdk, originalTitle, async (session) => { await withBackendProject(async (project) => {
await seedMessage(sdk, session.id) await withSession(project.sdk, originalTitle, async (session) => {
await gotoSession(session.id) await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle) await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
const menu = await openSessionMoreMenu(page, session.id) const menu = await openSessionMoreMenu(page, session.id)
@@ -54,7 +55,7 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.title return data?.title
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
@@ -63,22 +64,24 @@ test("session can be renamed via header menu", async ({ page, sdk, gotoSession }
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle) await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
}) })
})
}) })
test("session can be archived via header menu", async ({ page, sdk, gotoSession }) => { test("session can be archived via header menu", async ({ page, withBackendProject }) => {
const stamp = Date.now() const stamp = Date.now()
const title = `e2e archive test ${stamp}` const title = `e2e archive test ${stamp}`
await withSession(sdk, title, async (session) => { await withBackendProject(async (project) => {
await seedMessage(sdk, session.id) await withSession(project.sdk, title, async (session) => {
await gotoSession(session.id) await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id)
const menu = await openSessionMoreMenu(page, session.id) const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /archive/i) await clickMenuItem(menu, /archive/i)
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.time?.archived return data?.time?.archived
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
@@ -88,15 +91,17 @@ test("session can be archived via header menu", async ({ page, sdk, gotoSession
await openSidebar(page) await openSidebar(page)
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0) await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
}) })
})
}) })
test("session can be deleted via header menu", async ({ page, sdk, gotoSession }) => { test("session can be deleted via header menu", async ({ page, withBackendProject }) => {
const stamp = Date.now() const stamp = Date.now()
const title = `e2e delete test ${stamp}` const title = `e2e delete test ${stamp}`
await withSession(sdk, title, async (session) => { await withBackendProject(async (project) => {
await seedMessage(sdk, session.id) await withSession(project.sdk, title, async (session) => {
await gotoSession(session.id) await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id)
const menu = await openSessionMoreMenu(page, session.id) const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /delete/i) await clickMenuItem(menu, /delete/i)
await confirmDialog(page, /delete/i) await confirmDialog(page, /delete/i)
@@ -104,7 +109,7 @@ test("session can be deleted via header menu", async ({ page, sdk, gotoSession }
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await sdk.session const data = await project.sdk.session
.get({ sessionID: session.id }) .get({ sessionID: session.id })
.then((r) => r.data) .then((r) => r.data)
.catch(() => undefined) .catch(() => undefined)
@@ -117,17 +122,19 @@ test("session can be deleted via header menu", async ({ page, sdk, gotoSession }
await openSidebar(page) await openSidebar(page)
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0) await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
}) })
})
}) })
test("session can be shared and unshared via header button", async ({ page, sdk, gotoSession }) => { test("session can be shared and unshared via header button", async ({ page, withBackendProject }) => {
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).") test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
const stamp = Date.now() const stamp = Date.now()
const title = `e2e share test ${stamp}` const title = `e2e share test ${stamp}`
await withSession(sdk, title, async (session) => { await withBackendProject(async (project) => {
await seedMessage(sdk, session.id) await withSession(project.sdk, title, async (session) => {
await gotoSession(session.id) await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id)
const shared = await openSharePopover(page) const shared = await openSharePopover(page)
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first() const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
@@ -141,7 +148,7 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined return data?.share?.url || undefined
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
@@ -159,7 +166,7 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
await expect await expect
.poll( .poll(
async () => { async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data) const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined return data?.share?.url || undefined
}, },
{ timeout: 30_000 }, { timeout: 30_000 },
@@ -171,4 +178,5 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
timeout: 30_000, timeout: 30_000,
}) })
}) })
})
}) })
+7 -7
View File
@@ -26,21 +26,21 @@ export const serverNamePattern = new RegExp(`(?:${serverNames.map(escape).join("
export const modKey = process.platform === "darwin" ? "Meta" : "Control" export const modKey = process.platform === "darwin" ? "Meta" : "Control"
export const terminalToggleKey = "Control+Backquote" export const terminalToggleKey = "Control+Backquote"
export function createSdk(directory?: string) { export function createSdk(directory?: string, baseUrl = serverUrl) {
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true }) return createOpencodeClient({ baseUrl, directory, throwOnError: true })
} }
export async function resolveDirectory(directory: string) { export async function resolveDirectory(directory: string, baseUrl = serverUrl) {
return createSdk(directory) return createSdk(directory, baseUrl)
.path.get() .path.get()
.then((x) => x.data?.directory ?? directory) .then((x) => x.data?.directory ?? directory)
} }
export async function getWorktree() { export async function getWorktree(baseUrl = serverUrl) {
const sdk = createSdk() const sdk = createSdk(undefined, baseUrl)
const result = await sdk.path.get() const result = await sdk.path.get()
const data = result.data const data = result.data
if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${serverUrl}/path`) if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${baseUrl}/path`)
return data.worktree return data.worktree
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.3.5", "version": "1.3.13",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
+1 -1
View File
@@ -71,7 +71,7 @@ const serverEnv = {
OPENCODE_E2E_PROJECT_DIR: repoDir, OPENCODE_E2E_PROJECT_DIR: repoDir,
OPENCODE_E2E_SESSION_TITLE: "E2E Session", OPENCODE_E2E_SESSION_TITLE: "E2E Session",
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e", 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_CLIENT: "app",
OPENCODE_STRICT_CONFIG_DEPS: "true", OPENCODE_STRICT_CONFIG_DEPS: "true",
} satisfies Record<string, string> } satisfies Record<string, string>
+6 -2
View File
@@ -624,17 +624,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!cmd) return if (!cmd) return
promptProbe.select(cmd.id) promptProbe.select(cmd.id)
closePopover() closePopover()
const images = imageAttachments()
if (cmd.type === "custom") { if (cmd.type === "custom") {
const text = `/${cmd.trigger} ` const text = `/${cmd.trigger} `
setEditorText(text) 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() focusEditorEnd()
return return
} }
clearEditor() clearEditor()
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) prompt.set([...DEFAULT_PROMPT, ...images], 0)
command.trigger(cmd.id, "slash") command.trigger(cmd.id, "slash")
} }
@@ -1343,6 +1344,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
autocapitalize={store.mode === "normal" ? "sentences" : "off"} autocapitalize={store.mode === "normal" ? "sentences" : "off"}
autocorrect={store.mode === "normal" ? "on" : "off"} autocorrect={store.mode === "normal" ? "on" : "off"}
spellcheck={store.mode === "normal"} spellcheck={store.mode === "normal"}
inputMode="text"
// @ts-expect-error
autocomplete="off"
onInput={handleInput} onInput={handleInput}
onPaste={handlePaste} onPaste={handlePaste}
onCompositionStart={handleCompositionStart} onCompositionStart={handleCompositionStart}
@@ -100,6 +100,30 @@ describe("buildRequestParts", () => {
expect(synthetic).toHaveLength(1) expect(synthetic).toHaveLength(1)
}) })
test("adds file parts for @mentions inside comment text", () => {
const result = buildRequestParts({
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
context: [
{
key: "ctx:comment-mention",
type: "file",
path: "src/review.ts",
comment: "Compare with @src/shared.ts and @src/review.ts.",
},
],
images: [],
text: "look",
messageID: "msg_comment_mentions",
sessionID: "ses_comment_mentions",
sessionDirectory: "/repo",
})
const files = result.requestParts.filter((part) => part.type === "file")
expect(files).toHaveLength(2)
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/review.ts")).toBe(true)
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true)
})
test("handles Windows paths correctly (simulated on macOS)", () => { test("handles Windows paths correctly (simulated on macOS)", () => {
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }] const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
@@ -39,6 +39,16 @@ const absolute = (directory: string, path: string) => {
const fileQuery = (selection: FileSelection | undefined) => const fileQuery = (selection: FileSelection | undefined) =>
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : "" selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
const mention = /(^|[\s([{"'])@(\S+)/g
const parseCommentMentions = (comment: string) => {
return Array.from(comment.matchAll(mention)).flatMap((match) => {
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
if (!path) return []
return [path]
})
}
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file" const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent" const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
@@ -138,6 +148,21 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
if (!comment) return [filePart] if (!comment) return [filePart]
const mentions = parseCommentMentions(comment).flatMap((path) => {
const url = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
if (used.has(url)) return []
used.add(url)
return [
{
id: Identifier.ascending("part"),
type: "file",
mime: "text/plain",
url,
filename: getFilename(path),
} satisfies PromptRequestPart,
]
})
return [ return [
{ {
id: Identifier.ascending("part"), id: Identifier.ascending("part"),
@@ -153,6 +178,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
}), }),
} satisfies PromptRequestPart, } satisfies PromptRequestPart,
filePart, filePart,
...mentions,
] ]
}) })
+3
View File
@@ -1046,6 +1046,9 @@ export default function Page() {
onLineCommentUpdate={updateCommentInContext} onLineCommentUpdate={updateCommentInContext}
onLineCommentDelete={removeCommentFromContext} onLineCommentDelete={removeCommentFromContext}
lineCommentActions={reviewCommentActions()} lineCommentActions={reviewCommentActions()}
commentMentions={{
items: file.searchFilesAndDirectories,
}}
comments={comments.all()} comments={comments.all()}
focusedComment={comments.focus()} focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus} onFocusedCommentChange={comments.setFocus}
@@ -11,6 +11,51 @@ import { useSDK } from "@/context/sdk"
const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>() 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
ref?: (el: HTMLButtonElement) => void
onFocus?: VoidFunction
onClick: VoidFunction
}) {
return (
<button
type="button"
ref={props.ref}
data-slot="question-option"
data-picked={props.picked}
role={props.multi ? "checkbox" : "radio"}
aria-checked={props.picked}
disabled={props.disabled}
onFocus={props.onFocus}
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) => { export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
const sdk = useSDK() const sdk = useSDK()
const language = useLanguage() const language = useLanguage()
@@ -25,22 +70,30 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
custom: cached?.custom ?? ([] as string[]), custom: cached?.custom ?? ([] as string[]),
customOn: cached?.customOn ?? ([] as boolean[]), customOn: cached?.customOn ?? ([] as boolean[]),
editing: false, editing: false,
focus: 0,
}) })
let root: HTMLDivElement | undefined let root: HTMLDivElement | undefined
let customRef: HTMLButtonElement | undefined
let optsRef: HTMLButtonElement[] = []
let replied = false let replied = false
let focusFrame: number | undefined
const question = createMemo(() => questions()[store.tab]) const question = createMemo(() => questions()[store.tab])
const options = createMemo(() => question()?.options ?? []) const options = createMemo(() => question()?.options ?? [])
const input = createMemo(() => store.custom[store.tab] ?? "") const input = createMemo(() => store.custom[store.tab] ?? "")
const on = createMemo(() => store.customOn[store.tab] === true) const on = createMemo(() => store.customOn[store.tab] === true)
const multi = createMemo(() => question()?.multiple === true) const multi = createMemo(() => question()?.multiple === true)
const count = createMemo(() => options().length + 1)
const summary = createMemo(() => { const summary = createMemo(() => {
const n = Math.min(store.tab + 1, total()) const n = Math.min(store.tab + 1, total())
return language.t("session.question.progress", { current: n, total: total() }) 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 last = createMemo(() => store.tab >= total() - 1)
const customUpdate = (value: string, selected: boolean = on()) => { const customUpdate = (value: string, selected: boolean = on()) => {
@@ -85,6 +138,29 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
root.style.setProperty("--question-prompt-max-height", `${max}px`) root.style.setProperty("--question-prompt-max-height", `${max}px`)
} }
const clamp = (i: number) => Math.max(0, Math.min(count() - 1, i))
const pickFocus = (tab: number = store.tab) => {
const list = questions()[tab]?.options ?? []
if (store.customOn[tab] === true) return list.length
return Math.max(
0,
list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false),
)
}
const focus = (i: number) => {
const next = clamp(i)
setStore("focus", next)
if (store.editing) return
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
focusFrame = requestAnimationFrame(() => {
focusFrame = undefined
const el = next === options().length ? customRef : optsRef[next]
el?.focus()
})
}
onMount(() => { onMount(() => {
let raf: number | undefined let raf: number | undefined
const update = () => { const update = () => {
@@ -109,9 +185,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
observer.disconnect() observer.disconnect()
if (raf !== undefined) cancelAnimationFrame(raf) if (raf !== undefined) cancelAnimationFrame(raf)
}) })
focus(pickFocus())
}) })
onCleanup(() => { onCleanup(() => {
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
if (replied) return if (replied) return
cache.set(props.request.id, { cache.set(props.request.id, {
tab: store.tab, tab: store.tab,
@@ -164,6 +243,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? [])) 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) => { const pick = (answer: string, custom: boolean = false) => {
setStore("answers", store.tab, [answer]) setStore("answers", store.tab, [answer])
if (custom) setStore("custom", store.tab, answer) if (custom) setStore("custom", store.tab, answer)
@@ -180,6 +266,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const customToggle = () => { const customToggle = () => {
if (sending()) return if (sending()) return
setStore("focus", options().length)
if (!multi()) { if (!multi()) {
setStore("customOn", store.tab, true) setStore("customOn", store.tab, true)
@@ -199,15 +286,68 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const value = input().trim() const value = input().trim()
if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value)) if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
setStore("editing", false) setStore("editing", false)
focus(options().length)
} }
const customOpen = () => { const customOpen = () => {
if (sending()) return if (sending()) return
setStore("focus", options().length)
if (!on()) setStore("customOn", store.tab, true) if (!on()) setStore("customOn", store.tab, true)
setStore("editing", true) setStore("editing", true)
customUpdate(input(), true) customUpdate(input(), true)
} }
const move = (step: number) => {
if (store.editing || sending()) return
focus(store.focus + step)
}
const nav = (event: KeyboardEvent) => {
if (event.defaultPrevented) return
if (event.key === "Escape") {
event.preventDefault()
void reject()
return
}
const mod = (event.metaKey || event.ctrlKey) && !event.altKey
if (mod && event.key === "Enter") {
if (event.repeat) return
event.preventDefault()
next()
return
}
const target =
event.target instanceof HTMLElement ? event.target.closest('[data-slot="question-options"]') : undefined
if (store.editing) return
if (!(target instanceof HTMLElement)) return
if (event.altKey || event.ctrlKey || event.metaKey) return
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
event.preventDefault()
move(1)
return
}
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
event.preventDefault()
move(-1)
return
}
if (event.key === "Home") {
event.preventDefault()
focus(0)
return
}
if (event.key !== "End") return
event.preventDefault()
focus(count() - 1)
}
const selectOption = (optIndex: number) => { const selectOption = (optIndex: number) => {
if (sending()) return if (sending()) return
@@ -219,6 +359,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const opt = options()[optIndex] const opt = options()[optIndex]
if (!opt) return if (!opt) return
if (multi()) { if (multi()) {
setStore("editing", false)
toggle(opt.label) toggle(opt.label)
return return
} }
@@ -228,6 +369,25 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const commitCustom = () => { const commitCustom = () => {
setStore("editing", false) setStore("editing", false)
customUpdate(input()) customUpdate(input())
focus(options().length)
}
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 = () => { const next = () => {
@@ -239,27 +399,33 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
return return
} }
setStore("tab", store.tab + 1) const tab = store.tab + 1
setStore("tab", tab)
setStore("editing", false) setStore("editing", false)
focus(pickFocus(tab))
} }
const back = () => { const back = () => {
if (sending()) return if (sending()) return
if (store.tab <= 0) return if (store.tab <= 0) return
setStore("tab", store.tab - 1) const tab = store.tab - 1
setStore("tab", tab)
setStore("editing", false) setStore("editing", false)
focus(pickFocus(tab))
} }
const jump = (tab: number) => { const jump = (tab: number) => {
if (sending()) return if (sending()) return
setStore("tab", tab) setStore("tab", tab)
setStore("editing", false) setStore("editing", false)
focus(pickFocus(tab))
} }
return ( return (
<DockPrompt <DockPrompt
kind="question" kind="question"
ref={(el) => (root = el)} ref={(el) => (root = el)}
onKeyDown={nav}
header={ header={
<> <>
<div data-slot="question-header-title">{summary()}</div> <div data-slot="question-header-title">{summary()}</div>
@@ -270,10 +436,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
type="button" type="button"
data-slot="question-progress-segment" data-slot="question-progress-segment"
data-active={i() === store.tab} data-active={i() === store.tab}
data-answered={ data-answered={answered(i())}
(store.answers[i()]?.length ?? 0) > 0 ||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
}
disabled={sending()} disabled={sending()}
onClick={() => jump(i())} onClick={() => jump(i())}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`} aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
@@ -285,7 +448,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
footer={ footer={
<> <>
<Button variant="ghost" size="large" disabled={sending()} onClick={reject}> <Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
{language.t("ui.common.dismiss")} {language.t("ui.common.dismiss")}
</Button> </Button>
<div data-slot="question-footer-actions"> <div data-slot="question-footer-actions">
@@ -294,7 +457,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
{language.t("ui.common.back")} {language.t("ui.common.back")}
</Button> </Button>
</Show> </Show>
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}> <Button
variant={last() ? "primary" : "secondary"}
size="large"
disabled={sending()}
onClick={next}
aria-keyshortcuts="Meta+Enter Control+Enter"
>
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")} {last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
</Button> </Button>
</div> </div>
@@ -307,69 +476,39 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</Show> </Show>
<div data-slot="question-options"> <div data-slot="question-options">
<For each={options()}> <For each={options()}>
{(opt, i) => { {(opt, i) => (
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false <Option
return ( multi={multi()}
<button picked={picked(opt.label)}
data-slot="question-option" label={opt.label}
data-picked={picked()} description={opt.description}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
disabled={sending()} disabled={sending()}
ref={(el) => (optsRef[i()] = el)}
onFocus={() => setStore("focus", i())}
onClick={() => selectOption(i())} 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>
)
}}
</For> </For>
<Show <Show
when={store.editing} when={store.editing}
fallback={ fallback={
<button <button
type="button"
ref={customRef}
data-slot="question-option" data-slot="question-option"
data-custom="true" data-custom="true"
data-picked={on()} data-picked={on()}
role={multi() ? "checkbox" : "radio"} role={multi() ? "checkbox" : "radio"}
aria-checked={on()} aria-checked={on()}
disabled={sending()} disabled={sending()}
onFocus={() => setStore("focus", options().length)}
onClick={customOpen} onClick={customOpen}
> >
<span <Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
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>
<span data-slot="question-option-main"> <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>
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span> <span data-slot="option-description">{input() || customPlaceholder()}</span>
</span> </span>
</button> </button>
} }
@@ -394,33 +533,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
commitCustom() commitCustom()
}} }}
> >
<span <Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
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>
<span data-slot="question-option-main"> <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 <textarea
ref={(el) => ref={focusCustom}
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
data-slot="question-custom-input" data-slot="question-custom-input"
placeholder={language.t("ui.question.custom.placeholder")} placeholder={customPlaceholder()}
value={input()} value={input()}
rows={1} rows={1}
disabled={sending()} disabled={sending()}
@@ -428,16 +547,17 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault() e.preventDefault()
setStore("editing", false) setStore("editing", false)
focus(options().length)
return return
} }
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
if (e.key !== "Enter" || e.shiftKey) return if (e.key !== "Enter" || e.shiftKey) return
e.preventDefault() e.preventDefault()
commitCustom() commitCustom()
}} }}
onInput={(e) => { onInput={(e) => {
customUpdate(e.currentTarget.value) customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px" resizeInput(e.currentTarget)
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
}} }}
/> />
</span> </span>
+145 -131
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 }) { export function FileTabContent(props: { tab: string }) {
const file = useFile() const file = useFile()
const comments = useComments() const comments = useComments()
@@ -65,11 +191,6 @@ export function FileTabContent(props: { tab: string }) {
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
}).activeFileTab }).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 let find: FileSearchHandle | null = null
const search = { const search = {
@@ -92,6 +213,10 @@ export function FileTabContent(props: { tab: string }) {
if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
return (getSessionHandoff(sessionKey())?.files[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) => { const selectionPreview = (source: string, selection: FileSelection) => {
return previewSelectedLines(source, { 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: { const addCommentToContext = (input: {
file: string file: string
selection: SelectedLineRange selection: SelectedLineRange
@@ -108,14 +239,7 @@ export function FileTabContent(props: { tab: string }) {
origin?: "review" | "file" origin?: "review" | "file"
}) => { }) => {
const selection = selectionFromLines(input.selection) const selection = selectionFromLines(input.selection)
const preview = const preview = input.preview ?? buildPreview(input.file, selection)
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 saved = comments.add({ const saved = comments.add({
file: input.file, file: input.file,
@@ -140,8 +264,7 @@ export function FileTabContent(props: { tab: string }) {
comment: string comment: string
}) => { }) => {
comments.update(input.file, input.id, input.comment) comments.update(input.file, input.id, input.comment)
const preview = const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined
input.file === path() ? selectionPreview(contents(), selectionFromLines(input.selection)) : undefined
prompt.context.updateComment(input.file, input.id, { prompt.context.updateComment(input.file, input.id, {
comment: input.comment, comment: input.comment,
...(preview ? { preview } : {}), ...(preview ? { preview } : {}),
@@ -179,6 +302,9 @@ export function FileTabContent(props: { tab: string }) {
comments: fileComments, comments: fileComments,
label: language.t("ui.lineComment.submit"), label: language.t("ui.lineComment.submit"),
draftKey: () => path() ?? props.tab, draftKey: () => path() ?? props.tab,
mention: {
items: file.searchFilesAndDirectories,
},
state: { state: {
opened: () => note.openedComment, opened: () => note.openedComment,
setOpened: (id) => setNote("openedComment", id), setOpened: (id) => setNote("openedComment", id),
@@ -260,102 +386,6 @@ export function FileTabContent(props: { tab: string }) {
requestAnimationFrame(() => comments.clearFocus()) 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 cancelCommenting = () => {
const p = path() const p = path()
if (p) file.setSelectedLines(p, null) if (p) file.setSelectedLines(p, null)
@@ -375,16 +405,7 @@ export function FileTabContent(props: { tab: string }) {
const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active) const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
prev = { loaded, ready, active } prev = { loaded, ready, active }
if (!restore) return if (!restore) return
queueRestore() scrollSync.queueRestore()
})
onCleanup(() => {
for (const item of codeScroll) {
item.removeEventListener("scroll", handleCodeScroll)
}
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
}) })
const renderFile = (source: string) => ( const renderFile = (source: string) => (
@@ -402,7 +423,7 @@ export function FileTabContent(props: { tab: string }) {
selectedLines={activeSelection()} selectedLines={activeSelection()}
commentedLines={commentedLines()} commentedLines={commentedLines()}
onRendered={() => { onRendered={() => {
queueRestore() scrollSync.queueRestore()
}} }}
annotations={commentsUi.annotations()} annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation} renderAnnotation={commentsUi.renderAnnotation}
@@ -420,7 +441,7 @@ export function FileTabContent(props: { tab: string }) {
mode: "auto", mode: "auto",
path: path(), path: path(),
current: state()?.content, current: state()?.content,
onLoad: queueRestore, onLoad: scrollSync.queueRestore,
onError: (args: { kind: "image" | "audio" | "svg" }) => { onError: (args: { kind: "image" | "audio" | "svg" }) => {
if (args.kind !== "svg") return if (args.kind !== "svg") return
showToast({ showToast({
@@ -435,14 +456,7 @@ export function FileTabContent(props: { tab: string }) {
return ( return (
<Tabs.Content value={props.tab} class="mt-3 relative h-full"> <Tabs.Content value={props.tab} class="mt-3 relative h-full">
<ScrollView <ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
class="h-full"
viewportRef={(el: HTMLDivElement) => {
scroll = el
restoreScroll()
}}
onScroll={handleScroll as any}
>
<Switch> <Switch>
<Match when={state()?.loaded}>{renderFile(contents())}</Match> <Match when={state()?.loaded}>{renderFile(contents())}</Match>
<Match when={state()?.loading}> <Match when={state()?.loading}>
@@ -30,6 +30,9 @@ export interface SessionReviewTabProps {
onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
focusedFile?: string focusedFile?: string
onScrollRef?: (el: HTMLDivElement) => void onScrollRef?: (el: HTMLDivElement) => void
commentMentions?: {
items: (query: string) => string[] | Promise<string[]>
}
classes?: { classes?: {
root?: string root?: string
header?: string header?: string
@@ -162,6 +165,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
onLineCommentUpdate={props.onLineCommentUpdate} onLineCommentUpdate={props.onLineCommentUpdate}
onLineCommentDelete={props.onLineCommentDelete} onLineCommentDelete={props.onLineCommentDelete}
lineCommentActions={props.lineCommentActions} lineCommentActions={props.lineCommentActions}
lineCommentMention={props.commentMentions}
comments={props.comments} comments={props.comments}
focusedComment={props.focusedComment} focusedComment={props.focusedComment}
onFocusedCommentChange={props.onFocusedCommentChange} onFocusedCommentChange={props.onFocusedCommentChange}
@@ -128,25 +128,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory) if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
return permission.isAutoAcceptingDirectory(sdk.directory) return permission.isAutoAcceptingDirectory(sdk.directory)
} }
command.register("session", () => { const write = async (value: string) => {
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 = (value: string) => {
const body = typeof document === "undefined" ? undefined : document.body const body = typeof document === "undefined" ? undefined : document.body
if (body) { if (body) {
const textarea = document.createElement("textarea") const textarea = document.createElement("textarea")
@@ -159,20 +141,19 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
textarea.select() textarea.select()
const copied = document.execCommand("copy") const copied = document.execCommand("copy")
body.removeChild(textarea) body.removeChild(textarea)
if (copied) return Promise.resolve(true) if (copied) return true
} }
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
if (!clipboard?.writeText) return Promise.resolve(false) if (!clipboard?.writeText) return false
return clipboard.writeText(value).then( return clipboard.writeText(value).then(
() => true, () => true,
() => false, () => false,
) )
} }
const copy = async (url: string, existing: boolean) => { const copyShare = async (url: string, existing: boolean) => {
const ok = await write(url) if (!(await write(url))) {
if (!ok) {
showToast({ showToast({
title: language.t("toast.session.share.copyFailed.title"), title: language.t("toast.session.share.copyFailed.title"),
variant: "error", variant: "error",
@@ -181,22 +162,24 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
} }
showToast({ showToast({
title: existing title: existing ? language.t("session.share.copy.copied") : language.t("toast.session.share.success.title"),
? language.t("session.share.copy.copied")
: language.t("toast.session.share.success.title"),
description: language.t("toast.session.share.success.description"), description: language.t("toast.session.share.success.description"),
variant: "success", variant: "success",
}) })
} }
const share = async () => {
const sessionID = params.id
if (!sessionID) return
const existing = info()?.share?.url const existing = info()?.share?.url
if (existing) { if (existing) {
await copy(existing, true) await copyShare(existing, true)
return return
} }
const url = await sdk.client.session const url = await sdk.client.session
.share({ sessionID: params.id }) .share({ sessionID })
.then((res) => res.data?.share?.url) .then((res) => res.data?.share?.url)
.catch(() => undefined) .catch(() => undefined)
if (!url) { if (!url) {
@@ -208,19 +191,15 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
return return
} }
await copy(url, false) await copyShare(url, false)
}, }
}),
sessionCommand({ const unshare = async () => {
id: "session.unshare", const sessionID = params.id
title: language.t("command.session.unshare"), if (!sessionID) return
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 await sdk.client.session
.unshare({ sessionID: params.id }) .unshare({ sessionID })
.then(() => .then(() =>
showToast({ showToast({
title: language.t("toast.session.unshare.success.title"), title: language.t("toast.session.unshare.success.title"),
@@ -235,50 +214,24 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
variant: "error", variant: "error",
}), }),
) )
}, }
}),
]
return [ const openFile = () => {
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) => { void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />) dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
}) })
}, }
}),
fileCommand({ const closeTab = () => {
id: "tab.close",
title: language.t("command.tab.close"),
keybind: "mod+w",
disabled: !closableTab(),
onSelect: () => {
const tab = closableTab() const tab = closableTab()
if (!tab) return if (!tab) return
tabs().close(tab) tabs().close(tab)
}, }
}),
contextCommand({ const addSelection = () => {
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() const tab = activeFileTab()
if (!tab) return if (!tab) return
const path = file.pathFromTab(tab) const path = file.pathFromTab(tab)
if (!path) return if (!path) return
@@ -292,8 +245,209 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
} }
addSelectionToContext(path, selectionFromLines(range)) 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.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: share,
}), }),
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: unshare,
}),
]
}
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({ viewCommand({
id: "terminal.toggle", id: "terminal.toggle",
title: language.t("command.terminal.toggle"), title: language.t("command.terminal.toggle"),
@@ -319,16 +473,19 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
keybind: "ctrl+l", keybind: "ctrl+l",
onSelect: focusInput, onSelect: focusInput,
}), }),
]
const terminalCmds = () => [
terminalCommand({ terminalCommand({
id: "terminal.new", id: "terminal.new",
title: language.t("command.terminal.new"), title: language.t("command.terminal.new"),
description: language.t("command.terminal.new.description"), description: language.t("command.terminal.new.description"),
keybind: "ctrl+alt+t", keybind: "ctrl+alt+t",
onSelect: () => { onSelect: openTerminal,
if (terminal.all().length > 0) terminal.new()
view().terminal.open()
},
}), }),
]
const messageCmds = () => [
sessionCommand({ sessionCommand({
id: "message.previous", id: "message.previous",
title: language.t("command.message.previous"), title: language.t("command.message.previous"),
@@ -345,30 +502,38 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
disabled: !params.id, disabled: !params.id,
onSelect: () => navigateMessageByOffset(1), onSelect: () => navigateMessageByOffset(1),
}), }),
]
const modelCmds = () => [
modelCommand({ modelCommand({
id: "model.choose", id: "model.choose",
title: language.t("command.model.choose"), title: language.t("command.model.choose"),
description: language.t("command.model.choose.description"), description: language.t("command.model.choose.description"),
keybind: "mod+'", keybind: "mod+'",
slash: "model", slash: "model",
onSelect: () => { onSelect: chooseModel,
void import("@/components/dialog-select-model").then((x) => {
dialog.show(() => <x.DialogSelectModel model={local.model} />)
})
},
}), }),
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({ mcpCommand({
id: "mcp.toggle", id: "mcp.toggle",
title: language.t("command.mcp.toggle"), title: language.t("command.mcp.toggle"),
description: language.t("command.mcp.toggle.description"), description: language.t("command.mcp.toggle.description"),
keybind: "mod+;", keybind: "mod+;",
slash: "mcp", slash: "mcp",
onSelect: () => { onSelect: chooseMcp,
void import("@/components/dialog-select-mcp").then((x) => {
dialog.show(() => <x.DialogSelectMcp />)
})
},
}), }),
]
const agentCmds = () => [
agentCommand({ agentCommand({
id: "agent.cycle", id: "agent.cycle",
title: language.t("command.agent.cycle"), title: language.t("command.agent.cycle"),
@@ -384,13 +549,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
keybind: "shift+mod+.", keybind: "shift+mod+.",
onSelect: () => local.agent.move(-1), onSelect: () => local.agent.move(-1),
}), }),
modelCommand({ ]
id: "model.variant.cycle",
title: language.t("command.model.variant.cycle"), const permissionsCmds = () => [
description: language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d",
onSelect: () => local.model.variant.cycle(),
}),
permissionsCommand({ permissionsCommand({
id: "permissions.autoaccept", id: "permissions.autoaccept",
title: isAutoAcceptActive() title: isAutoAcceptActive()
@@ -398,110 +559,21 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
: language.t("command.permissions.autoaccept.enable"), : language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a", keybind: "mod+shift+a",
disabled: false, disabled: false,
onSelect: () => { onSelect: 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"),
})
},
}), }),
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,
] ]
})
command.register("session", () => [
...sessionCmds(),
...shareCmds(),
...fileCmds(),
...contextCmds(),
...viewCmds(),
...terminalCmds(),
...messageCmds(),
...modelCmds(),
...mcpCmds(),
...agentCmds(),
...permissionsCmds(),
])
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.3.5", "version": "1.3.13",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -3
View File
@@ -432,9 +432,7 @@ export default function Home() {
</Faq> </Faq>
</li> </li>
<li> <li>
<Faq question={i18n.t("go.faq.q5")}> <Faq question={i18n.t("go.faq.q5")}>{i18n.t("go.faq.a5.body")}</Faq>
{i18n.t("go.faq.a5.body")} <a href="mailto:contact@anoma.ly">{i18n.t("common.contactUs")}</a>
</Faq>
</li> </li>
<li> <li>
<Faq question={i18n.t("go.faq.q6")}>{i18n.t("go.faq.a6")}</Faq> <Faq question={i18n.t("go.faq.q6")}>{i18n.t("go.faq.a6")}</Faq>
@@ -139,8 +139,7 @@ export async function handler(
const startTimestamp = Date.now() const startTimestamp = Date.now()
const reqUrl = providerInfo.modifyUrl(providerInfo.api, isStream) const reqUrl = providerInfo.modifyUrl(providerInfo.api, isStream)
const reqBody = JSON.stringify( const reqBody = JSON.stringify(
providerInfo.modifyBody( providerInfo.modifyBody({
{
...createBodyConverter(opts.format, providerInfo.format)(body), ...createBodyConverter(opts.format, providerInfo.format)(body),
model: providerInfo.model, model: providerInfo.model,
...(providerInfo.payloadModifier ?? {}), ...(providerInfo.payloadModifier ?? {}),
@@ -149,9 +148,7 @@ export async function handler(
.map(([k, v]) => [k, input.request.headers.get(v)]) .map(([k, v]) => [k, input.request.headers.get(v)])
.filter(([_k, v]) => !!v), .filter(([_k, v]) => !!v),
), ),
}, }),
authInfo?.workspaceID,
),
) )
logger.debug("REQUEST URL: " + reqUrl) logger.debug("REQUEST URL: " + reqUrl)
logger.debug("REQUEST: " + reqBody.substring(0, 300) + "...") logger.debug("REQUEST: " + reqBody.substring(0, 300) + "...")
@@ -470,15 +467,17 @@ export async function handler(
...(() => { ...(() => {
const providerProps = zenData.providers[modelProvider.id] const providerProps = zenData.providers[modelProvider.id]
const format = providerProps.format const format = providerProps.format
const providerModel = modelProvider.model const opts = {
if (format === "anthropic") return anthropicHelper({ reqModel, providerModel })
if (format === "google") return googleHelper({ reqModel, providerModel })
if (format === "openai") return openaiHelper({ reqModel, providerModel })
return oaCompatHelper({
reqModel, reqModel,
providerModel, providerModel: modelProvider.model,
adjustCacheUsage: providerProps.adjustCacheUsage, 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)
})(), })(),
} }
} }
@@ -21,17 +21,18 @@ type Usage = {
} }
} }
export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentifier }) => ({
format: "oa-compat", format: "oa-compat",
modifyUrl: (providerApi: string) => providerApi + "/chat/completions", modifyUrl: (providerApi: string) => providerApi + "/chat/completions",
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => { modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("authorization", `Bearer ${apiKey}`) headers.set("authorization", `Bearer ${apiKey}`)
headers.set("x-session-affinity", headers.get("x-opencode-session") ?? "") headers.set("x-session-affinity", headers.get("x-opencode-session") ?? "")
}, },
modifyBody: (body: Record<string, any>) => { modifyBody: (body: Record<string, any>, workspaceID?: string) => {
return { return {
...body, ...body,
...(body.stream ? { stream_options: { include_usage: true } } : {}), ...(body.stream ? { stream_options: { include_usage: true } } : {}),
...(safetyIdentifier ? { safety_identifier: safetyIdentifier } : {}),
} }
}, },
createBinaryStreamDecoder: () => undefined, createBinaryStreamDecoder: () => undefined,
@@ -12,13 +12,13 @@ type Usage = {
total_tokens?: number total_tokens?: number
} }
export const openaiHelper: ProviderHelper = () => ({ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
format: "openai", format: "openai",
modifyUrl: (providerApi: string) => providerApi + "/responses", modifyUrl: (providerApi: string) => providerApi + "/responses",
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => { modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("authorization", `Bearer ${apiKey}`) headers.set("authorization", `Bearer ${apiKey}`)
}, },
modifyBody: (body: Record<string, any>, workspaceID?: string) => ({ modifyBody: (body: Record<string, any>) => ({
...body, ...body,
...(workspaceID ? { safety_identifier: workspaceID } : {}), ...(workspaceID ? { safety_identifier: workspaceID } : {}),
}), }),
@@ -33,11 +33,17 @@ export type UsageInfo = {
cacheWrite1hTokens?: number 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 format: ZenData.Format
modifyUrl: (providerApi: string, isStream?: boolean) => string modifyUrl: (providerApi: string, isStream?: boolean) => string
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void 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 createBinaryStreamDecoder: () => ((chunk: Uint8Array) => Uint8Array | undefined) | undefined
streamSeparator: string streamSeparator: string
createUsageParser: () => { createUsageParser: () => {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.3.5", "version": "1.3.13",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -10,7 +10,7 @@ if (!stage) throw new Error("Stage is required")
const root = path.resolve(process.cwd(), "..", "..", "..") const root = path.resolve(process.cwd(), "..", "..", "..")
// read the secret // 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 lines = ret.split("\n")
const value = lines.find((line) => line.startsWith("ZEN_LIMITS"))?.split("=")[1] const value = lines.find((line) => line.startsWith("ZEN_LIMITS"))?.split("=")[1]
if (!value) throw new Error("ZEN_LIMITS not found") if (!value) throw new Error("ZEN_LIMITS not found")
@@ -12,7 +12,7 @@ const root = path.resolve(process.cwd(), "..", "..", "..")
const PARTS = 30 const PARTS = 30
// read the secret // 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 lines = ret.split("\n")
const values = Array.from({ length: PARTS }, (_, i) => { const values = Array.from({ length: PARTS }, (_, i) => {
const value = lines const value = lines
@@ -6,7 +6,7 @@ import os from "os"
import { Subscription } from "../src/subscription" import { Subscription } from "../src/subscription"
const root = path.resolve(process.cwd(), "..", "..", "..") 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 // read value
const lines = secrets.split("\n") const lines = secrets.split("\n")
@@ -25,4 +25,4 @@ const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
Subscription.validate(JSON.parse(newValue)) Subscription.validate(JSON.parse(newValue))
// update the secret // update the secret
await $`bun sst secret set ZEN_LIMITS ${newValue}` await $`bun sst secret set ZEN_LIMITS ${newValue} --stage frank`.cwd(root)
@@ -6,7 +6,7 @@ import os from "os"
import { ZenData } from "../src/model" import { ZenData } from "../src/model"
const root = path.resolve(process.cwd(), "..", "..", "..") 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 const PARTS = 30
// read the line starting with "ZEN_MODELS" // 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`)) 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 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)
+1
View File
@@ -37,6 +37,7 @@ export namespace ZenData {
disabled: z.boolean().optional(), disabled: z.boolean().optional(),
storeModel: z.string().optional(), storeModel: z.string().optional(),
payloadModifier: z.record(z.string(), z.any()).optional(), payloadModifier: z.record(z.string(), z.any()).optional(),
safetyIdentifier: z.boolean().optional(),
}), }),
), ),
}) })
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.3.5", "version": "1.3.13",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.3.5", "version": "1.3.13",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"private": true, "private": true,
"version": "1.3.5", "version": "1.3.13",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"homepage": "https://opencode.ai", "homepage": "https://opencode.ai",
+10 -7
View File
@@ -9,6 +9,7 @@ import { app } from "electron"
import treeKill from "tree-kill" import treeKill from "tree-kill"
import { WSL_ENABLED_KEY } from "./constants" import { WSL_ENABLED_KEY } from "./constants"
import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env"
import { store } from "./store" import { store } from "./store"
const CLI_INSTALL_DIR = ".opencode/bin" const CLI_INSTALL_DIR = ".opencode/bin"
@@ -135,7 +136,7 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
const base = Object.fromEntries( const base = Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"), Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
) )
const envs = { const env = {
...base, ...base,
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
@@ -143,8 +144,10 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
XDG_STATE_HOME: app.getPath("userData"), XDG_STATE_HOME: app.getPath("userData"),
...extraEnv, ...extraEnv,
} }
const shell = process.platform === "win32" ? null : getUserShell()
const envs = shell ? mergeShellEnv(loadShellEnv(shell), env) : env
const { cmd, cmdArgs } = buildCommand(args, envs) const { cmd, cmdArgs } = buildCommand(args, envs, shell)
console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`) console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
const child = spawn(cmd, cmdArgs, { const child = spawn(cmd, cmdArgs, {
env: envs, env: envs,
@@ -210,7 +213,7 @@ function handleSqliteProgress(events: EventEmitter, line: string) {
return false return false
} }
function buildCommand(args: string, env: Record<string, string>) { function buildCommand(args: string, env: Record<string, string>, shell: string | null) {
if (process.platform === "win32" && isWslEnabled()) { if (process.platform === "win32" && isWslEnabled()) {
console.log(`[cli] Using WSL mode`) console.log(`[cli] Using WSL mode`)
const version = app.getVersion() const version = app.getVersion()
@@ -233,10 +236,10 @@ function buildCommand(args: string, env: Record<string, string>) {
} }
const sidecar = getSidecarPath() const sidecar = getSidecarPath()
const shell = process.env.SHELL || "/bin/sh" const user = shell || getUserShell()
const line = shell.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}` const line = user.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
console.log(`[cli] Unix mode, shell: ${shell}, command: ${line}`) console.log(`[cli] Unix mode, shell: ${user}, command: ${line}`)
return { cmd: shell, cmdArgs: ["-l", "-c", line] } return { cmd: user, cmdArgs: ["-l", "-c", line] }
} }
function envPrefix(env: Record<string, string>) { function envPrefix(env: Record<string, string>) {
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test"
import { isNushell, mergeShellEnv, parseShellEnv } from "./shell-env"
describe("shell env", () => {
test("parseShellEnv supports null-delimited pairs", () => {
const env = parseShellEnv(Buffer.from("PATH=/usr/bin:/bin\0FOO=bar=baz\0\0"))
expect(env.PATH).toBe("/usr/bin:/bin")
expect(env.FOO).toBe("bar=baz")
})
test("parseShellEnv ignores invalid entries", () => {
const env = parseShellEnv(Buffer.from("INVALID\0=empty\0OK=1\0"))
expect(Object.keys(env).length).toBe(1)
expect(env.OK).toBe("1")
})
test("mergeShellEnv keeps explicit overrides", () => {
const env = mergeShellEnv(
{
PATH: "/shell/path",
HOME: "/tmp/home",
},
{
PATH: "/desktop/path",
OPENCODE_CLIENT: "desktop",
},
)
expect(env.PATH).toBe("/desktop/path")
expect(env.HOME).toBe("/tmp/home")
expect(env.OPENCODE_CLIENT).toBe("desktop")
})
test("isNushell handles path and binary name", () => {
expect(isNushell("nu")).toBe(true)
expect(isNushell("/opt/homebrew/bin/nu")).toBe(true)
expect(isNushell("C:\\Program Files\\nu.exe")).toBe(true)
expect(isNushell("/bin/zsh")).toBe(false)
})
})
@@ -0,0 +1,88 @@
import { spawnSync } from "node:child_process"
import { basename } from "node:path"
const SHELL_ENV_TIMEOUT = 5_000
type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
export function getUserShell() {
return process.env.SHELL || "/bin/sh"
}
export function parseShellEnv(out: Buffer) {
const env: Record<string, string> = {}
for (const line of out.toString("utf8").split("\0")) {
if (!line) continue
const ix = line.indexOf("=")
if (ix <= 0) continue
env[line.slice(0, ix)] = line.slice(ix + 1)
}
return env
}
function probeShellEnv(shell: string, mode: "-il" | "-l"): Probe {
const out = spawnSync(shell, [mode, "-c", "env -0"], {
stdio: ["ignore", "pipe", "ignore"],
timeout: SHELL_ENV_TIMEOUT,
windowsHide: true,
})
const err = out.error as NodeJS.ErrnoException | undefined
if (err) {
if (err.code === "ETIMEDOUT") return { type: "Timeout" }
console.log(`[cli] Shell env probe failed for ${shell} ${mode}: ${err.message}`)
return { type: "Unavailable" }
}
if (out.status !== 0) {
console.log(`[cli] Shell env probe exited with non-zero status for ${shell} ${mode}`)
return { type: "Unavailable" }
}
const env = parseShellEnv(out.stdout)
if (Object.keys(env).length === 0) {
console.log(`[cli] Shell env probe returned empty env for ${shell} ${mode}`)
return { type: "Unavailable" }
}
return { type: "Loaded", value: env }
}
export function isNushell(shell: string) {
const name = basename(shell).toLowerCase()
const raw = shell.toLowerCase()
return name === "nu" || name === "nu.exe" || raw.endsWith("\\nu.exe")
}
export function loadShellEnv(shell: string) {
if (isNushell(shell)) {
console.log(`[cli] Skipping shell env probe for nushell: ${shell}`)
return null
}
const interactive = probeShellEnv(shell, "-il")
if (interactive.type === "Loaded") {
console.log(`[cli] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
return interactive.value
}
if (interactive.type === "Timeout") {
console.warn(`[cli] Interactive shell env probe timed out: ${shell}`)
return null
}
const login = probeShellEnv(shell, "-l")
if (login.type === "Loaded") {
console.log(`[cli] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
return login.value
}
console.warn(`[cli] Falling back to app environment: ${shell}`)
return null
}
export function mergeShellEnv(shell: Record<string, string> | null, env: Record<string, string>) {
return {
...(shell || {}),
...env,
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.3.5", "version": "1.3.13",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.3.5", "version": "1.3.13",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.3.5" version = "1.3.13"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.5/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.5/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.5/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.5/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.5/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.3.5", "version": "1.3.13",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+5 -4
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.3.5", "version": "1.3.13",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -87,7 +87,7 @@
"@ai-sdk/provider-utils": "4.0.21", "@ai-sdk/provider-utils": "4.0.21",
"@ai-sdk/togetherai": "2.0.41", "@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39", "@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", "@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
@@ -102,8 +102,8 @@
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "2.3.3", "@openrouter/ai-sdk-provider": "2.3.3",
"@opentui/core": "0.1.92", "@opentui/core": "0.1.95",
"@opentui/solid": "0.1.92", "@opentui/solid": "0.1.95",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
@@ -142,6 +142,7 @@
"solid-js": "catalog:", "solid-js": "catalog:",
"strip-ansi": "7.1.2", "strip-ansi": "7.1.2",
"tree-sitter-bash": "0.25.0", "tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0", "turndown": "7.2.0",
"ulid": "catalog:", "ulid": "catalog:",
"vscode-jsonrpc": "8.2.1", "vscode-jsonrpc": "8.2.1",
+15
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 title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session"
const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e" const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e"
const model = process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano" 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 parts = model.split("/")
const providerID = parts[0] ?? "opencode" const providerID = parts[0] ?? "opencode"
const modelID = parts[1] ?? "gpt-5-nano" const modelID = parts[1] ?? "gpt-5-nano"
@@ -11,6 +12,7 @@ const seed = async () => {
const { Instance } = await import("../src/project/instance") const { Instance } = await import("../src/project/instance")
const { InstanceBootstrap } = await import("../src/project/bootstrap") const { InstanceBootstrap } = await import("../src/project/bootstrap")
const { Config } = await import("../src/config/config") const { Config } = await import("../src/config/config")
const { Provider } = await import("../src/provider/provider")
const { Session } = await import("../src/session") const { Session } = await import("../src/session")
const { MessageID, PartID } = await import("../src/session/schema") const { MessageID, PartID } = await import("../src/session/schema")
const { Project } = await import("../src/project/project") const { Project } = await import("../src/project/project")
@@ -25,6 +27,19 @@ const seed = async () => {
await Config.waitForDependencies() await Config.waitForDependencies()
await ToolRegistry.ids() 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 session = await Session.create({ title })
const messageID = MessageID.ascending() const messageID = MessageID.ascending()
const partID = PartID.ascending() const partID = PartID.ascending()
+6 -8
View File
@@ -210,15 +210,13 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
- [x] `Vcs``project/vcs.ts` - [x] `Vcs``project/vcs.ts`
- [x] `Worktree``worktree/index.ts` - [x] `Worktree``worktree/index.ts`
Still open and likely worth migrating:
- [x] `Session``session/index.ts` - [x] `Session``session/index.ts`
- [ ] `SessionProcessor`blocked by AI SDK v6 PR (#18433) - [x] `SessionProcessor``session/processor.ts`
- [ ] `SessionPrompt`blocked by AI SDK v6 PR (#18433) - [x] `SessionPrompt``session/prompt.ts`
- [ ] `SessionCompaction`blocked by AI SDK v6 PR (#18433) - [x] `SessionCompaction``session/compaction.ts`
- [ ] `Provider`blocked by AI SDK v6 PR (#18433) - [x] `Provider``provider/provider.ts`
Other services not yet migrated: Still open:
- [ ] `SessionSummary``session/summary.ts` - [ ] `SessionSummary``session/summary.ts`
- [ ] `SessionTodo``session/todo.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 1. Migrate each tool to return Effects
2. Update `Tool.define()` factory to work with 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: Individual tools, ordered by value:
+39 -14
View File
@@ -84,17 +84,27 @@ export default plugin
- TUI shape is `default export { id?, tui }`; including `server` is rejected. - TUI shape is `default export { id?, tui }`; including `server` is rejected.
- A single module cannot export both `server` and `tui`. - A single module cannot export both `server` and `tui`.
- `tui` signature is `(api, options, meta) => Promise<void>`. - `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. - 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`. - File/path plugins must export a non-empty `id`.
- npm plugins may omit `id`; package `name` is used. - 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. - 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`. - There is no directory auto-discovery for TUI plugins; they must be listed in `tui.json`.
## Package manifest and install ## 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: Example:
@@ -102,14 +112,20 @@ Example:
{ {
"name": "@acme/opencode-plugin", "name": "@acme/opencode-plugin",
"type": "module", "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": { "engines": {
"opencode": "^1.0.0" "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`. - Local installs resolve target dir inside `patchPluginConfig`.
- For local scope, path is `<worktree>/.opencode` only when VCS is git and `worktree !== "/"`; otherwise `<directory>/.opencode`. - 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. - 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` 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. - 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. - 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. - Tuple targets in `oc-plugin` provide default options written into config.
- A package can target `server`, `tui`, or both. - 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. - 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.app.version`
- `api.command.register(cb)` / `api.command.trigger(value)` - `api.command.register(cb)` / `api.command.trigger(value)`
- `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current` - `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.keybind.match`, `print`, `create`
- `api.tuiConfig` - `api.tuiConfig`
- `api.kv.get`, `set`, `ready` - `api.kv.get`, `set`, `ready`
@@ -210,6 +232,7 @@ Command behavior:
- `ui.Dialog` is the base dialog wrapper. - `ui.Dialog` is the base dialog wrapper.
- `ui.DialogAlert`, `ui.DialogConfirm`, `ui.DialogPrompt`, `ui.DialogSelect` are built-in dialog components. - `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.toast(...)` shows a toast.
- `ui.dialog` exposes the host dialog stack: - `ui.dialog` exposes the host dialog stack:
- `replace(render, onClose?)` - `replace(render, onClose?)`
@@ -266,7 +289,9 @@ Theme install behavior:
- Relative theme paths are resolved from the plugin root. - Relative theme paths are resolved from the plugin root.
- Theme name is the JSON basename. - 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. - 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. - Global plugins persist installed themes under the global `themes` dir.
- Invalid or unreadable theme files are ignored. - Invalid or unreadable theme files are ignored.
@@ -277,6 +302,7 @@ Current host slot names:
- `app` - `app`
- `home_logo` - `home_logo`
- `home_prompt` with props `{ workspace_id? }`
- `home_bottom` - `home_bottom`
- `sidebar_title` with props `{ session_id, title, share_url? }` - `sidebar_title` with props `{ session_id, title, share_url? }`
- `sidebar_content` with props `{ session_id }` - `sidebar_content` with props `{ session_id }`
@@ -289,7 +315,7 @@ Slot notes:
- `api.slots.register(plugin)` does not return an unregister function. - `api.slots.register(plugin)` does not return an unregister function.
- Returned ids are `pluginId`, `pluginId:1`, `pluginId:2`, and so on. - Returned ids are `pluginId`, `pluginId:1`, `pluginId:2`, and so on.
- Plugin-provided `id` is not allowed. - 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. - Plugins cannot define new slot names in this branch.
### Plugin control and lifecycle ### 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(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(...)` 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. - `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`. - If activation fails, the plugin can remain `enabled=true` and `active=false`.
- `api.lifecycle.signal` is aborted before cleanup runs. - `api.lifecycle.signal` is aborted before cleanup runs.
- `api.lifecycle.onDispose(fn)` registers cleanup and returns an unregister function. - `api.lifecycle.onDispose(fn)` registers cleanup and returns an unregister function.
+26 -3
View File
@@ -1,4 +1,4 @@
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect" import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
@@ -175,9 +175,8 @@ export namespace Account {
mapAccountServiceError("HTTP request failed"), mapAccountServiceError("HTTP request failed"),
) )
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) { const refreshToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis const now = yield* Clock.currentTimeMillis
if (row.token_expiry && row.token_expiry > now) return row.access_token
const response = yield* executeEffectOk( const response = yield* executeEffectOk(
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe( HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
@@ -208,6 +207,30 @@ export namespace Account {
return parsed.access_token return parsed.access_token
}) })
const refreshTokenCache = yield* Cache.make<AccountID, AccessToken, AccountError>({
capacity: Number.POSITIVE_INFINITY,
timeToLive: Duration.zero,
lookup: Effect.fnUntraced(function* (accountID) {
const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) {
return yield* Effect.fail(new AccountServiceError({ message: "Account not found during token refresh" }))
}
const account = maybeAccount.value
const now = yield* Clock.currentTimeMillis
if (account.token_expiry && account.token_expiry > now) return account.access_token
return yield* refreshToken(account)
}),
})
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (row.token_expiry && row.token_expiry > now) return row.access_token
return yield* Cache.get(refreshTokenCache, row.id)
})
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) { const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
const maybeAccount = yield* repo.getRow(accountID) const maybeAccount = yield* repo.getRow(accountID)
if (Option.isNone(maybeAccount)) return Option.none() if (Option.isNone(maybeAccount)) return Option.none()
+6 -4
View File
@@ -75,6 +75,7 @@ export namespace Agent {
const config = yield* Config.Service const config = yield* Config.Service
const auth = yield* Auth.Service const auth = yield* Auth.Service
const skill = yield* Skill.Service const skill = yield* Skill.Service
const provider = yield* Provider.Service
const state = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Agent.state")(function* (ctx) { Effect.fn("Agent.state")(function* (ctx) {
@@ -330,9 +331,9 @@ export namespace Agent {
model?: { providerID: ProviderID; modelID: ModelID } model?: { providerID: ProviderID; modelID: ModelID }
}) { }) {
const cfg = yield* config.get() const cfg = yield* config.get()
const model = input.model ?? (yield* Effect.promise(() => Provider.defaultModel())) const model = input.model ?? (yield* provider.defaultModel())
const resolved = yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID)) const resolved = yield* provider.getModel(model.providerID, model.modelID)
const language = yield* Effect.promise(() => Provider.getLanguage(resolved)) const language = yield* provider.getLanguage(resolved)
const system = [PROMPT_GENERATE] const system = [PROMPT_GENERATE]
yield* Effect.promise(() => yield* Effect.promise(() =>
@@ -393,7 +394,8 @@ export namespace Agent {
) )
export const defaultLayer = layer.pipe( export const defaultLayer = layer.pipe(
Layer.provide(Auth.layer), Layer.provide(Provider.defaultLayer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer), Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer), Layer.provide(Skill.defaultLayer),
) )
+12 -18
View File
@@ -3,7 +3,7 @@ import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { zod } from "@/util/effect-zod" import { zod } from "@/util/effect-zod"
import { Global } from "../global" import { Global } from "../global"
import { Filesystem } from "../util/filesystem" import { AppFileSystem } from "../filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
@@ -53,17 +53,13 @@ export namespace Auth {
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const decode = Schema.decodeUnknownOption(Info) const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("Auth.all")(() => const all = Effect.fn("Auth.all")(function* () {
Effect.tryPromise({ const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>
try: async () => {
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined)) return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
}, })
catch: fail("Failed to read auth data"),
}),
)
const get = Effect.fn("Auth.get")(function* (providerID: string) { const get = Effect.fn("Auth.get")(function* (providerID: string) {
return (yield* all())[providerID] return (yield* all())[providerID]
@@ -74,10 +70,9 @@ export namespace Auth {
const data = yield* all() const data = yield* all()
if (norm !== key) delete data[key] if (norm !== key) delete data[key]
delete data[norm + "/"] delete data[norm + "/"]
yield* Effect.tryPromise({ yield* fsys
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600), .writeJson(file, { ...data, [norm]: info }, 0o600)
catch: fail("Failed to write auth data"), .pipe(Effect.mapError(fail("Failed to write auth data")))
})
}) })
const remove = Effect.fn("Auth.remove")(function* (key: string) { const remove = Effect.fn("Auth.remove")(function* (key: string) {
@@ -85,17 +80,16 @@ export namespace Auth {
const data = yield* all() const data = yield* all()
delete data[key] delete data[key]
delete data[norm] delete data[norm]
yield* Effect.tryPromise({ yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data")))
try: () => Filesystem.writeJson(file, data, 0o600),
catch: fail("Failed to write auth data"),
})
}) })
return Service.of({ get, all, set, remove }) 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) { export async function get(providerID: string) {
return runPromise((service) => service.get(providerID)) return runPromise((service) => service.get(providerID))
+2 -1
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 // Use lock to ensure only one install at a time
using _ = await Lock.write("bun-install") using _ = await Lock.write("bun-install")
@@ -82,6 +82,7 @@ export namespace BunProc {
"add", "add",
"--force", "--force",
"--exact", "--exact",
...(opts?.ignoreScripts ? ["--ignore-scripts"] : []),
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936) // TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() || process.env.CI ? ["--no-cache"] : []), ...(proxied() || process.env.CI ? ["--no-cache"] : []),
"--cwd", "--cwd",
+14 -13
View File
@@ -46,7 +46,7 @@ export namespace Bus {
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const cache = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Bus.state")(function* (ctx) { Effect.fn("Bus.state")(function* (ctx) {
const wildcard = yield* PubSub.unbounded<Payload>() const wildcard = yield* PubSub.unbounded<Payload>()
const typed = new Map<string, PubSub.PubSub<Payload>>() const typed = new Map<string, PubSub.PubSub<Payload>>()
@@ -82,16 +82,17 @@ export namespace Bus {
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) { function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
return Effect.gen(function* () { return Effect.gen(function* () {
const state = yield* InstanceState.get(cache) const s = yield* InstanceState.get(state)
const payload: Payload = { type: def.type, properties } const payload: Payload = { type: def.type, properties }
log.info("publishing", { type: def.type }) log.info("publishing", { type: def.type })
const ps = state.typed.get(def.type) const ps = s.typed.get(def.type)
if (ps) yield* PubSub.publish(ps, payload) if (ps) yield* PubSub.publish(ps, payload)
yield* PubSub.publish(state.wildcard, payload) yield* PubSub.publish(s.wildcard, payload)
const dir = yield* InstanceState.directory
GlobalBus.emit("event", { GlobalBus.emit("event", {
directory: Instance.directory, directory: dir,
payload, payload,
}) })
}) })
@@ -101,8 +102,8 @@ export namespace Bus {
log.info("subscribing", { type: def.type }) log.info("subscribing", { type: def.type })
return Stream.unwrap( return Stream.unwrap(
Effect.gen(function* () { Effect.gen(function* () {
const state = yield* InstanceState.get(cache) const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(state, def) const ps = yield* getOrCreate(s, def)
return Stream.fromPubSub(ps) return Stream.fromPubSub(ps)
}), }),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type })))) ).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
@@ -112,8 +113,8 @@ export namespace Bus {
log.info("subscribing", { type: "*" }) log.info("subscribing", { type: "*" })
return Stream.unwrap( return Stream.unwrap(
Effect.gen(function* () { Effect.gen(function* () {
const state = yield* InstanceState.get(cache) const s = yield* InstanceState.get(state)
return Stream.fromPubSub(state.wildcard) return Stream.fromPubSub(s.wildcard)
}), }),
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" })))) ).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
} }
@@ -149,14 +150,14 @@ export namespace Bus {
def: D, def: D,
callback: (event: Payload<D>) => unknown, callback: (event: Payload<D>) => unknown,
) { ) {
const state = yield* InstanceState.get(cache) const s = yield* InstanceState.get(state)
const ps = yield* getOrCreate(state, def) const ps = yield* getOrCreate(s, def)
return yield* on(ps, def.type, callback) return yield* on(ps, def.type, callback)
}) })
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) { const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
const state = yield* InstanceState.get(cache) const s = yield* InstanceState.get(state)
return yield* on(state.wildcard, "*", callback) return yield* on(s.wildcard, "*", callback)
}) })
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback }) return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
+2 -2
View File
@@ -114,8 +114,8 @@ export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps
if (manifest.code === "manifest_no_targets") { if (manifest.code === "manifest_no_targets") {
inspect.stop("No plugin targets found", 1) inspect.stop("No plugin targets found", 1)
dep.log.error(`"${mod}" does not declare supported targets in package.json`) dep.log.error(`"${mod}" does not expose plugin entrypoints in package.json`)
dep.log.info('Expected: "oc-plugin": ["server", "tui"] or tuples like [["tui", { ... }]].') dep.log.info('Expected one of: exports["./tui"], exports["./server"], or package.json main for server.')
return false return false
} }
+14 -2
View File
@@ -125,6 +125,7 @@ import { DialogVariant } from "./component/dialog-variant"
function rendererConfig(_config: TuiConfig.Info): CliRendererConfig { function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
return { return {
externalOutputMode: "passthrough",
targetFps: 60, targetFps: 60,
gatherStats: false, gatherStats: false,
exitOnCtrlC: false, exitOnCtrlC: false,
@@ -250,7 +251,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
const route = useRoute() const route = useRoute()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const renderer = useRenderer() const renderer = useRenderer()
renderer.disableStdoutInterception()
const dialog = useDialog() const dialog = useDialog()
const local = useLocal() const local = useLocal()
const kv = useKV() const kv = useKV()
@@ -581,10 +581,22 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
}, },
}, },
{ {
title: "Switch model variant", title: "Variant cycle",
value: "variant.cycle", value: "variant.cycle",
keybind: "variant_cycle", keybind: "variant_cycle",
category: "Agent", category: "Agent",
onSelect: () => {
local.model.variant.cycle()
},
},
{
title: "Switch model variant",
value: "variant.list",
category: "Agent",
hidden: local.model.variant.list().length === 0,
slash: {
name: "variants",
},
onSelect: () => { onSelect: () => {
dialog.replace(() => <DialogVariant />) dialog.replace(() => <DialogVariant />)
}, },
@@ -136,7 +136,13 @@ export function DialogModel(props: { providerID?: string }) {
function onSelect(providerID: string, modelID: string) { function onSelect(providerID: string, modelID: string) {
local.model.set({ providerID, modelID }, { recent: true }) 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 />) dialog.replace(() => <DialogVariant />)
return return
} }
@@ -8,23 +8,32 @@ export function DialogVariant() {
const dialog = useDialog() const dialog = useDialog()
const options = createMemo(() => { const options = createMemo(() => {
return local.model.variant.list().map((variant) => ({ return [
{
value: "default",
title: "Default",
onSelect: () => {
dialog.clear()
local.model.variant.set(undefined)
},
},
...local.model.variant.list().map((variant) => ({
value: variant, value: variant,
title: variant, title: variant,
onSelect: () => { onSelect: () => {
dialog.clear() dialog.clear()
local.model.variant.set(variant) local.model.variant.set(variant)
}, },
})) })),
]
}) })
return ( return (
<DialogSelect<string> <DialogSelect<string>
options={options()} options={options()}
title={"Select variant"} title={"Select variant"}
current={local.model.variant.current()} current={local.model.variant.selected()}
flat={true} flat={true}
skipFilter={true}
/> />
) )
} }
@@ -57,7 +57,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return agents() return agents()
}, },
current() { current() {
return agents().find((x) => x.name === agentStore.current)! return agents().find((x) => x.name === agentStore.current) ?? agents()[0]
}, },
set(name: string) { set(name: string) {
if (!agents().some((x) => x.name === name)) if (!agents().some((x) => x.name === name))
@@ -321,12 +321,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}) })
}, },
variant: { variant: {
current() { selected() {
const m = currentModel() const m = currentModel()
if (!m) return undefined if (!m) return undefined
const key = `${m.providerID}/${m.modelID}` const key = `${m.providerID}/${m.modelID}`
return modelStore.variant[key] return modelStore.variant[key]
}, },
current() {
const v = this.selected()
if (!v) return undefined
if (!this.list().includes(v)) return undefined
return v
},
list() { list() {
const m = currentModel() const m = currentModel()
if (!m) return [] if (!m) return []
@@ -339,7 +345,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const m = currentModel() const m = currentModel()
if (!m) return if (!m) return
const key = `${m.providerID}/${m.modelID}` const key = `${m.providerID}/${m.modelID}`
setModelStore("variant", key, value) setModelStore("variant", key, value ?? "default")
save() save()
}, },
cycle() { cycle() {
@@ -183,6 +183,18 @@ export function addTheme(name: string, theme: unknown) {
return true 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") { export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
const defs = theme.defs ?? {} const defs = theme.defs ?? {}
function resolveColor(c: ColorValue, chain: string[] = []): RGBA { function resolveColor(c: ColorValue, chain: string[] = []): RGBA {
@@ -62,8 +62,8 @@
"light": "frappeText" "light": "frappeText"
}, },
"textMuted": { "textMuted": {
"dark": "frappeSubtext1", "dark": "frappeOverlay2",
"light": "frappeSubtext1" "light": "frappeOverlay2"
}, },
"background": { "background": {
"dark": "frappeBase", "dark": "frappeBase",
@@ -62,8 +62,8 @@
"light": "macText" "light": "macText"
}, },
"textMuted": { "textMuted": {
"dark": "macSubtext1", "dark": "macOverlay2",
"light": "macSubtext1" "light": "macOverlay2"
}, },
"background": { "background": {
"dark": "macBase", "dark": "macBase",
@@ -63,7 +63,7 @@
"success": { "dark": "darkGreen", "light": "lightGreen" }, "success": { "dark": "darkGreen", "light": "lightGreen" },
"info": { "dark": "darkTeal", "light": "lightTeal" }, "info": { "dark": "darkTeal", "light": "lightTeal" },
"text": { "dark": "darkText", "light": "lightText" }, "text": { "dark": "darkText", "light": "lightText" },
"textMuted": { "dark": "darkSubtext1", "light": "lightSubtext1" }, "textMuted": { "dark": "darkOverlay2", "light": "lightOverlay2" },
"background": { "dark": "darkBase", "light": "lightBase" }, "background": { "dark": "darkBase", "light": "lightBase" },
"backgroundPanel": { "dark": "darkMantle", "light": "lightMantle" }, "backgroundPanel": { "dark": "darkMantle", "light": "lightMantle" },
"backgroundElement": { "dark": "darkCrust", "light": "lightCrust" }, "backgroundElement": { "dark": "darkCrust", "light": "lightCrust" },
@@ -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
@@ -1,3 +1,4 @@
import HomeFooter from "../feature-plugins/home/footer"
import HomeTips from "../feature-plugins/home/tips" import HomeTips from "../feature-plugins/home/tips"
import SidebarContext from "../feature-plugins/sidebar/context" import SidebarContext from "../feature-plugins/sidebar/context"
import SidebarMcp from "../feature-plugins/sidebar/mcp" import SidebarMcp from "../feature-plugins/sidebar/mcp"
@@ -14,6 +15,7 @@ export type InternalTuiPlugin = TuiPluginModule & {
} }
export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [ export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [
HomeFooter,
HomeTips, HomeTips,
SidebarContext, SidebarContext,
SidebarMcp, SidebarMcp,
@@ -18,38 +18,29 @@ import { Log } from "@/util/log"
import { errorData, errorMessage } from "@/util/error" import { errorData, errorMessage } from "@/util/error"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { import { pluginSource, readPluginId, readV1Plugin, resolvePluginId, type PluginSource } from "@/plugin/shared"
checkPluginCompatibility, import { PluginLoader } from "@/plugin/loader"
isDeprecatedPlugin,
pluginSource,
readPluginId,
readV1Plugin,
resolvePluginEntrypoint,
resolvePluginId,
resolvePluginTarget,
type PluginSource,
} from "@/plugin/shared"
import { PluginMeta } from "@/plugin/meta" import { PluginMeta } from "@/plugin/meta"
import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install" 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 { Global } from "@/global"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process" import { Process } from "@/util/process"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Installation } from "@/installation"
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal" import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
import { setupSlots, Slot as View } from "./slots" import { setupSlots, Slot as View } from "./slots"
import type { HostPluginApi, HostSlots } from "./slots" import type { HostPluginApi, HostSlots } from "./slots"
type PluginLoad = { type PluginLoad = {
item?: Config.PluginSpec options: Config.PluginOptions | undefined
spec: string spec: string
target: string target: string
retry: boolean retry: boolean
source: PluginSource | "internal" source: PluginSource | "internal"
id: string id: string
module: TuiPluginModule module: TuiPluginModule
install_theme: TuiTheme["install"] theme_meta: TuiConfig.PluginMeta
theme_root: string
} }
type Api = HostPluginApi type Api = HostPluginApi
@@ -64,8 +55,8 @@ type PluginEntry = {
id: string id: string
load: PluginLoad load: PluginLoad
meta: TuiPluginMeta meta: TuiPluginMeta
themes: Record<string, PluginMeta.Theme>
plugin: TuiPlugin plugin: TuiPlugin
options: Config.PluginOptions | undefined
enabled: boolean enabled: boolean
scope?: PluginScope scope?: PluginScope
} }
@@ -76,13 +67,7 @@ type RuntimeState = {
slots: HostSlots slots: HostSlots
plugins: PluginEntry[] plugins: PluginEntry[]
plugins_by_id: Map<string, PluginEntry> plugins_by_id: Map<string, PluginEntry>
pending: Map< pending: Map<string, TuiConfig.PluginRecord>
string,
{
item: Config.PluginSpec
meta: TuiConfig.PluginMeta
}
>
} }
const log = Log.create({ service: "tui.plugin" }) 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) 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" } type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> { function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
@@ -143,12 +133,54 @@ function resolveRoot(root: string) {
return path.resolve(process.cwd(), root) 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) => { return async (file) => {
const raw = file.startsWith("file://") ? fileURLToPath(file) : file const raw = file.startsWith("file://") ? fileURLToPath(file) : file
const src = path.isAbsolute(raw) ? raw : path.resolve(root, raw) const src = path.isAbsolute(raw) ? raw : path.resolve(root, raw)
const theme = path.basename(src, path.extname(src)) const name = path.basename(src, path.extname(src))
if (hasTheme(theme)) 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, `${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) => { const text = await Filesystem.readText(src).catch((error) => {
log.warn("failed to read tui plugin theme", { path: spec, theme: src, 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 return
} }
const source_dir = path.dirname(meta.source) if (exists || !(await Filesystem.exists(dest))) {
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))) {
await Filesystem.write(dest, text).catch((error) => { await Filesystem.write(dest, text).catch((error) => {
log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, 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( async function loadExternalPlugin(cfg: TuiConfig.PluginRecord, retry = false): Promise<PluginLoad | undefined> {
item: Config.PluginSpec, const plan = PluginLoader.plan(cfg.item)
meta: TuiConfig.PluginMeta | undefined, if (plan.deprecated) return
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
const source = pluginSource(spec) log.info("loading tui plugin", { path: plan.spec, retry })
if (source === "npm") { const resolved = await PluginLoader.resolve(plan, "tui")
const ok = await checkPluginCompatibility(resolved, Installation.VERSION) if (!resolved.ok) {
.then(() => true) if (resolved.stage === "missing") {
.catch((error) => { warn("tui plugin has no entrypoint", {
fail("tui plugin incompatible", { path: spec, retry, error }) path: plan.spec,
return false
})
if (!ok) return
}
const target = resolved
if (!meta) {
fail("missing tui plugin metadata", {
path: spec,
retry, retry,
message: resolved.message,
}) })
return return
} }
const root = resolveRoot(source === "file" ? spec : target) if (resolved.stage === "install") {
const install_theme = createThemeInstaller(meta, root, spec) fail("failed to resolve tui plugin", { path: plan.spec, retry, error: resolved.error })
const entry = await resolvePluginEntrypoint(spec, target, "tui").catch((error) => {
fail("failed to resolve tui plugin entry", { path: spec, target, retry, error })
return return
}) }
if (!entry) 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 mod = await import(entry) const loaded = await PluginLoader.load(resolved.value)
.then((raw) => { if (!loaded.ok) {
return readV1Plugin(raw as Record<string, unknown>, spec, "tui") as TuiPluginModule fail("failed to load tui plugin", {
path: plan.spec,
target: resolved.value.entry,
retry,
error: loaded.error,
})
return
}
const mod = await Promise.resolve()
.then(() => {
return readV1Plugin(loaded.value.mod as Record<string, unknown>, plan.spec, "tui") as TuiPluginModule
}) })
.catch((error) => { .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 return
}) })
if (!mod) return if (!mod) return
const id = await resolvePluginId(source, spec, target, readPluginId(mod.id, spec)).catch((error) => { const id = await resolvePluginId(
fail("failed to load tui plugin", { path: spec, target, retry, error }) 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 return
}) })
if (!id) return if (!id) return
return { return {
item, options: plan.options,
spec, spec: plan.spec,
target, target: loaded.value.target,
retry, retry,
source, source: loaded.value.source,
id, id,
module: mod, 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 const target = spec
return { return {
options: undefined,
spec, spec,
target, target,
retry: false, retry: false,
source: "internal", source: "internal",
id: item.id, id: item.id,
module: item, module: item,
install_theme: createThemeInstaller( theme_meta: {
{
scope: "global", scope: "global",
source: target, source: target,
}, },
process.cwd(), theme_root: process.cwd(),
spec,
),
} }
} }
@@ -436,10 +486,10 @@ async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, per
if (plugin.scope) return true if (plugin.scope) return true
const scope = createPluginScope(plugin.load, plugin.id) 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() const ok = await Promise.resolve()
.then(async () => { .then(async () => {
await plugin.plugin(api, plugin.options, plugin.meta) await plugin.plugin(api, plugin.load.options, plugin.meta)
return true return true
}) })
.catch((error) => { .catch((error) => {
@@ -479,9 +529,10 @@ async function deactivatePluginById(state: RuntimeState | undefined, id: string,
return deactivatePluginEntry(state, plugin, persist) 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 api = runtime.api
const host = runtime.slots const host = runtime.slots
const load = plugin.load
const command: TuiPluginApi["command"] = { const command: TuiPluginApi["command"] = {
register(cb) { register(cb) {
return scope.track(api.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), { 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"] = { 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) { function addPluginEntry(state: RuntimeState, plugin: PluginEntry) {
if (state.plugins_by_id.has(plugin.id)) { if (state.plugins_by_id.has(plugin.id)) {
fail("duplicate tui plugin id", { fail("duplicate tui plugin id", {
@@ -600,12 +637,8 @@ function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.I
} }
} }
async function resolveExternalPlugins( async function resolveExternalPlugins(list: TuiConfig.PluginRecord[], wait: () => Promise<void>) {
list: Config.PluginSpec[], const loaded = await Promise.all(list.map((item) => loadExternalPlugin(item)))
wait: () => Promise<void>,
meta: (item: Config.PluginSpec) => TuiConfig.PluginMeta | undefined,
) {
const loaded = await Promise.all(list.map((item) => loadExternalPlugin(item, meta(item))))
const ready: PluginLoad[] = [] const ready: PluginLoad[] = []
let deps: Promise<void> | undefined let deps: Promise<void> | undefined
@@ -614,13 +647,12 @@ async function resolveExternalPlugins(
if (!entry) { if (!entry) {
const item = list[i] const item = list[i]
if (!item) continue if (!item) continue
const spec = Config.pluginSpecifier(item) if (pluginSource(Config.pluginSpecifier(item.item)) !== "file") continue
if (pluginSource(spec) !== "file") continue
deps ??= wait().catch((error) => { deps ??= wait().catch((error) => {
log.warn("failed waiting for tui plugin dependencies", { error }) log.warn("failed waiting for tui plugin dependencies", { error })
}) })
await deps await deps
entry = await loadExternalPlugin(item, meta(item), true) entry = await loadExternalPlugin(item, true)
} }
if (!entry) continue if (!entry) continue
ready.push(entry) 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) const row = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
for (const plugin of collectPluginEntries(entry, row)) { 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)) { if (!addPluginEntry(state, plugin)) {
ok = false ok = false
continue continue
} }
plugins.push(plugin) plugins.push(plugin)
} }
}
return { plugins, ok } return { plugins, ok }
} }
function defaultPluginMeta(state: RuntimeState): TuiConfig.PluginMeta { function defaultPluginRecord(state: RuntimeState, spec: string): TuiConfig.PluginRecord {
return { return {
item: spec,
scope: "local", scope: "local",
source: state.api.state.path.config || path.join(state.directory, ".opencode", "tui.json"), 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() const spec = raw.trim()
if (!spec) return false if (!spec) return false
const pending = state.pending.get(spec) const cfg = state.pending.get(spec) ?? defaultPluginRecord(state, spec)
const item = pending?.item ?? spec const next = Config.pluginSpecifier(cfg.item)
const nextSpec = Config.pluginSpecifier(item) if (state.plugins.some((plugin) => plugin.load.spec === next)) {
if (state.plugins.some((plugin) => plugin.load.spec === nextSpec)) {
state.pending.delete(spec) state.pending.delete(spec)
return true return true
} }
const meta = pending?.meta ?? defaultPluginMeta(state)
const ready = await Instance.provide({ const ready = await Instance.provide({
directory: state.directory, directory: state.directory,
fn: () => fn: () => resolveExternalPlugins([cfg], () => TuiConfig.waitForDependencies()),
resolveExternalPlugins(
[item],
() => TuiConfig.waitForDependencies(),
() => meta,
),
}).catch((error) => { }).catch((error) => {
fail("failed to add tui plugin", { path: nextSpec, error }) fail("failed to add tui plugin", { path: next, error })
return [] as PluginLoad[] return [] as PluginLoad[]
}) })
if (!ready.length) { if (!ready.length) {
fail("failed to add tui plugin", { path: nextSpec })
return false return false
} }
const first = ready[0] const first = ready[0]
if (!first) { if (!first) {
fail("failed to add tui plugin", { path: nextSpec }) fail("failed to add tui plugin", { path: next })
return false return false
} }
if (state.plugins_by_id.has(first.id)) { 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) state.pending.delete(spec)
if (!ok) { if (!ok) {
fail("failed to add tui plugin", { path: nextSpec }) fail("failed to add tui plugin", { path: next })
} }
return ok return ok
} }
@@ -806,7 +837,7 @@ async function installPluginBySpec(
if (manifest.code === "manifest_no_targets") { if (manifest.code === "manifest_no_targets") {
return { return {
ok: false, 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") const tui = manifest.targets.find((item) => item.kind === "tui")
if (tui) { if (tui) {
const file = patch.items.find((item) => item.kind === "tui")?.file 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, { state.pending.set(spec, {
item: tui.opts ? [spec, tui.opts] : spec, item,
meta: {
scope: global ? "global" : "local", scope: global ? "global" : "local",
source: (file ?? dir.config) || path.join(patch.dir, "tui.json"), source: (file ?? dir.config) || path.join(patch.dir, "tui.json"),
},
}) })
} }
@@ -929,25 +959,26 @@ export namespace TuiPluginRuntime {
directory: cwd, directory: cwd,
fn: async () => { fn: async () => {
const config = await TuiConfig.get() const config = await TuiConfig.get()
const plugins = Flag.OPENCODE_PURE ? [] : (config.plugin ?? []) const records = Flag.OPENCODE_PURE ? [] : (config.plugin_records ?? [])
if (Flag.OPENCODE_PURE && config.plugin?.length) { if (Flag.OPENCODE_PURE && config.plugin_records?.length) {
log.info("skipping external tui plugins in pure mode", { count: config.plugin.length }) log.info("skipping external tui plugins in pure mode", { count: config.plugin_records.length })
} }
for (const item of INTERNAL_TUI_PLUGINS) { for (const item of INTERNAL_TUI_PLUGINS) {
log.info("loading internal tui plugin", { id: item.id }) log.info("loading internal tui plugin", { id: item.id })
const entry = loadInternalPlugin(item) const entry = loadInternalPlugin(item)
const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id) const meta = createMeta(entry.source, entry.spec, entry.target, undefined, entry.id)
for (const plugin of collectPluginEntries(entry, meta)) { addPluginEntry(next, {
addPluginEntry(next, plugin) id: entry.id,
} load: entry,
meta,
themes: {},
plugin: entry.module.tui,
enabled: true,
})
} }
const ready = await resolveExternalPlugins( const ready = await resolveExternalPlugins(records, () => TuiConfig.waitForDependencies())
plugins,
() => TuiConfig.waitForDependencies(),
(item) => config.plugin_meta?.[Config.pluginSpecifier(item)],
)
await addExternalPluginEntries(next, ready) await addExternalPluginEntries(next, ready)
applyInitialPluginEnabledState(next, config) applyInitialPluginEnabledState(next, config)
@@ -1,15 +1,11 @@
import { Prompt, type PromptRef } from "@tui/component/prompt" import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createEffect, createMemo, Match, on, onMount, Show, Switch } from "solid-js" import { createEffect, on, onMount } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { Logo } from "../component/logo" import { Logo } from "../component/logo"
import { Locale } from "@/util/locale"
import { useSync } from "../context/sync" import { useSync } from "../context/sync"
import { Toast } from "../ui/toast" import { Toast } from "../ui/toast"
import { useArgs } from "../context/args" import { useArgs } from "../context/args"
import { useDirectory } from "../context/directory"
import { useRouteData } from "@tui/context/route" import { useRouteData } from "@tui/context/route"
import { usePromptRef } from "../context/prompt" import { usePromptRef } from "../context/prompt"
import { Installation } from "@/installation"
import { useLocal } from "../context/local" import { useLocal } from "../context/local"
import { TuiPluginRuntime } from "../plugin" import { TuiPluginRuntime } from "../plugin"
@@ -22,37 +18,8 @@ const placeholder = {
export function Home() { export function Home() {
const sync = useSync() const sync = useSync()
const { theme } = useTheme()
const route = useRouteData("home") const route = useRouteData("home")
const promptRef = usePromptRef() 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 | undefined let prompt: PromptRef | undefined
const args = useArgs() const args = useArgs()
const local = useLocal() const local = useLocal()
@@ -81,7 +48,6 @@ export function Home() {
}, },
), ),
) )
const directory = useDirectory()
return ( return (
<> <>
@@ -101,7 +67,6 @@ export function Home() {
prompt = r prompt = r
promptRef.set(r) promptRef.set(r)
}} }}
hint={Hint}
workspaceID={route.workspaceID} workspaceID={route.workspaceID}
placeholders={placeholder} placeholders={placeholder}
/> />
@@ -111,28 +76,8 @@ export function Home() {
<box flexGrow={1} minHeight={0} /> <box flexGrow={1} minHeight={0} />
<Toast /> <Toast />
</box> </box>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}> <box width="100%" flexShrink={0}>
<text fg={theme.textMuted}>{directory()}</text> <TuiPluginRuntime.Slot name="home_footer" mode="single_winner" />
<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> </box>
</> </>
) )
@@ -387,6 +387,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
}} }}
initialValue={input()} initialValue={input()}
placeholder="Type your own answer" placeholder="Type your own answer"
placeholderColor={theme.textMuted}
minHeight={1} minHeight={1}
maxHeight={6} maxHeight={6}
textColor={theme.text} textColor={theme.text}
@@ -103,6 +103,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) {
ref={(val: TextareaRenderable) => (textarea = val)} ref={(val: TextareaRenderable) => (textarea = val)}
initialValue={props.defaultFilename} initialValue={props.defaultFilename}
placeholder="Enter filename" placeholder="Enter filename"
placeholderColor={theme.textMuted}
textColor={theme.text} textColor={theme.text}
focusedTextColor={theme.text} focusedTextColor={theme.text}
cursorColor={theme.text} cursorColor={theme.text}
@@ -74,6 +74,7 @@ export function DialogPrompt(props: DialogPromptProps) {
ref={(val: TextareaRenderable) => (textarea = val)} ref={(val: TextareaRenderable) => (textarea = val)}
initialValue={props.value} initialValue={props.value}
placeholder={props.placeholder ?? "Enter text"} placeholder={props.placeholder ?? "Enter text"}
placeholderColor={theme.textMuted}
textColor={props.busy ? theme.textMuted : theme.text} textColor={props.busy ? theme.textMuted : theme.text}
focusedTextColor={props.busy ? theme.textMuted : theme.text} focusedTextColor={props.busy ? theme.textMuted : theme.text}
cursorColor={props.busy ? theme.backgroundElement : theme.text} cursorColor={props.busy ? theme.backgroundElement : theme.text}
@@ -260,6 +260,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}, 1) }, 1)
}} }}
placeholder={props.placeholder ?? "Search"} placeholder={props.placeholder ?? "Search"}
placeholderColor={theme.textMuted}
/> />
</box> </box>
</box> </box>
+6 -6
View File
@@ -85,7 +85,7 @@ export namespace Command {
commands[Default.INIT] = { commands[Default.INIT] = {
name: Default.INIT, name: Default.INIT,
description: "create/update AGENTS.md", description: "guided AGENTS.md setup",
source: "command", source: "command",
get template() { get template() {
return PROMPT_INITIALIZE.replace("${path}", ctx.worktree) return PROMPT_INITIALIZE.replace("${path}", ctx.worktree)
@@ -161,16 +161,16 @@ export namespace Command {
} }
}) })
const cache = yield* InstanceState.make<State>((ctx) => init(ctx)) const state = yield* InstanceState.make<State>((ctx) => init(ctx))
const get = Effect.fn("Command.get")(function* (name: string) { const get = Effect.fn("Command.get")(function* (name: string) {
const state = yield* InstanceState.get(cache) const s = yield* InstanceState.get(state)
return state.commands[name] return s.commands[name]
}) })
const list = Effect.fn("Command.list")(function* () { const list = Effect.fn("Command.list")(function* () {
const state = yield* InstanceState.get(cache) const s = yield* InstanceState.get(state)
return Object.values(state.commands) return Object.values(s.commands)
}) })
return Service.of({ get, list }) return Service.of({ get, list })
@@ -1,10 +1,66 @@
Please analyze this codebase and create an AGENTS.md file containing: Create or update `AGENTS.md` for this repository.
1. Build/lint/test commands - especially for running a single test
2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 150 lines long. The goal is a compact instruction file that helps future OpenCode sessions avoid mistakes and ramp up quickly. Every line should answer: "Would an agent likely miss this without help?" If not, leave it out.
If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.
If there's already an AGENTS.md, improve it if it's located in ${path}
User-provided focus or constraints (honor these):
$ARGUMENTS $ARGUMENTS
## How to investigate
Read the highest-value sources first:
- `README*`, root manifests, workspace config, lockfiles
- build, test, lint, formatter, typecheck, and codegen config
- CI workflows and pre-commit / task runner config
- existing instruction files (`AGENTS.md`, `CLAUDE.md`, `.cursor/rules/`, `.cursorrules`, `.github/copilot-instructions.md`)
- repo-local OpenCode config such as `opencode.json`
If architecture is still unclear after reading config and docs, inspect a small number of representative code files to find the real entrypoints, package boundaries, and execution flow. Prefer reading the files that explain how the system is wired together over random leaf files.
Prefer executable sources of truth over prose. If docs conflict with config or scripts, trust the executable source and only keep what you can verify.
## What to extract
Look for the highest-signal facts for an agent working in this repo:
- exact developer commands, especially non-obvious ones
- how to run a single test, a single package, or a focused verification step
- required command order when it matters, such as `lint -> typecheck -> test`
- monorepo or multi-package boundaries, ownership of major directories, and the real app/library entrypoints
- framework or toolchain quirks: generated code, migrations, codegen, build artifacts, special env loading, dev servers, infra deploy flow
- repo-specific style or workflow conventions that differ from defaults
- testing quirks: fixtures, integration test prerequisites, snapshot workflows, required services, flaky or expensive suites
- important constraints from existing instruction files worth preserving
Good `AGENTS.md` content is usually hard-earned context that took reading multiple files to infer.
## Questions
Only ask the user questions if the repo cannot answer something important. Use the `question` tool for one short batch at most.
Good questions:
- undocumented team conventions
- branch / PR / release expectations
- missing setup or test prerequisites that are known but not written down
Do not ask about anything the repo already makes clear.
## Writing rules
Include only high-signal, repo-specific guidance such as:
- exact commands and shortcuts the agent would otherwise guess wrong
- architecture notes that are not obvious from filenames
- conventions that differ from language or framework defaults
- setup requirements, environment quirks, and operational gotchas
- references to existing instruction sources that matter
Exclude:
- generic software advice
- long tutorials or exhaustive file trees
- obvious language conventions
- speculative claims or anything you could not verify
- content better stored in another file referenced via `opencode.json` `instructions`
When in doubt, omit.
Prefer short sections and bullets. If the repo is simple, keep the file simple. If the repo is large, summarize the few structural facts that actually change how an agent should work.
If `AGENTS.md` already exists at `${path}`, improve it in place rather than rewriting blindly. Preserve verified useful guidance, delete fluff or stale claims, and reconcile it with the current codebase.
+17 -29
View File
@@ -1,7 +1,6 @@
import { Log } from "../util/log" import { Log } from "../util/log"
import path from "path" import path from "path"
import { pathToFileURL } from "url" import { pathToFileURL } from "url"
import { createRequire } from "module"
import os from "os" import os from "os"
import z from "zod" import z from "zod"
import { ModelsDev } from "../provider/models" import { ModelsDev } from "../provider/models"
@@ -122,7 +121,10 @@ export namespace Config {
const gitignore = path.join(dir, ".gitignore") const gitignore = path.join(dir, ".gitignore")
const ignore = await Filesystem.exists(gitignore) const ignore = await Filesystem.exists(gitignore)
if (!ignore) { 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. // Bun can race cache writes on Windows when installs run in parallel across dirs.
@@ -366,34 +368,19 @@ export namespace Config {
export async function resolvePluginSpec(plugin: PluginSpec, configFilepath: string): Promise<PluginSpec> { export async function resolvePluginSpec(plugin: PluginSpec, configFilepath: string): Promise<PluginSpec> {
const spec = pluginSpecifier(plugin) const spec = pluginSpecifier(plugin)
if (!isPathPluginSpec(spec)) return plugin if (!isPathPluginSpec(spec)) return plugin
if (spec.startsWith("file://")) {
const resolved = await resolvePathPluginTarget(spec).catch(() => spec) 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]] if (Array.isArray(plugin)) return [resolved, plugin[1]]
return resolved 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
}
}
}
/** /**
* Deduplicates plugins by name, with later entries (higher priority) winning. * Deduplicates plugins by name, with later entries (higher priority) winning.
@@ -1499,7 +1486,8 @@ export namespace Config {
}) })
const update = Effect.fn("Config.update")(function* (config: Info) { const update = Effect.fn("Config.update")(function* (config: Info) {
const file = path.join(Instance.directory, "config.json") const dir = yield* InstanceState.directory
const file = path.join(dir, "config.json")
const existing = yield* loadFile(file) const existing = yield* loadFile(file)
yield* fs.writeFileString(file, JSON.stringify(mergeDeep(existing, config), null, 2)).pipe(Effect.orDie) yield* fs.writeFileString(file, JSON.stringify(mergeDeep(existing, config), null, 2)).pipe(Effect.orDie)
yield* Effect.promise(() => Instance.dispose()) yield* Effect.promise(() => Instance.dispose())
@@ -1556,7 +1544,7 @@ export namespace Config {
export const defaultLayer = layer.pipe( export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer), Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Auth.layer), Layer.provide(Auth.defaultLayer),
Layer.provide(Account.defaultLayer), Layer.provide(Account.defaultLayer),
) )
+15 -5
View File
@@ -22,6 +22,12 @@ export namespace TuiConfig {
source: string source: string
} }
export type PluginRecord = {
item: Config.PluginSpec
scope: PluginMeta["scope"]
source: string
}
type PluginEntry = { type PluginEntry = {
item: Config.PluginSpec item: Config.PluginSpec
meta: PluginMeta meta: PluginMeta
@@ -33,7 +39,8 @@ export namespace TuiConfig {
} }
export type Info = z.output<typeof Info> & { 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"] { function pluginScope(file: string): PluginMeta["scope"] {
@@ -149,10 +156,13 @@ export namespace TuiConfig {
const merged = dedupePlugins(acc.entries) const merged = dedupePlugins(acc.entries)
acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {}) acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {})
acc.result.plugin = merged.map((item) => item.item) const list = merged.map((item) => ({
acc.result.plugin_meta = merged.length item: item.item,
? Object.fromEntries(merged.map((item) => [Config.pluginSpecifier(item.item), item.meta])) scope: item.meta.scope,
: undefined source: item.meta.source,
}))
acc.result.plugin = list.map((item) => item.item)
acc.result.plugin_records = list.length ? list : undefined
const deps: Promise<void>[] = [] const deps: Promise<void>[] = []
if (acc.result.plugin?.length) { if (acc.result.plugin?.length) {
@@ -386,9 +386,17 @@ export const make = Effect.gen(function* () {
if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup)) if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup))
return yield* Effect.void return yield* Effect.void
} }
return yield* kill((command, proc, signal) => const send = (s: NodeJS.Signals) =>
Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)), Effect.catch(killGroup(command, proc, s), () => killOne(command, proc, s))
).pipe(Effect.andThen(Deferred.await(signal)), Effect.ignore) const sig = command.options.killSignal ?? "SIGTERM"
const attempt = send(sig).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid)
const escalated = command.options.forceKillAfter
? Effect.timeoutOrElse(attempt, {
duration: command.options.forceKillAfter,
orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
})
: attempt
return yield* Effect.ignore(escalated)
}), }),
) )
@@ -413,14 +421,17 @@ export const make = Effect.gen(function* () {
), ),
) )
}), }),
kill: (opts?: ChildProcess.KillOptions) => kill: (opts?: ChildProcess.KillOptions) => {
timeout( const sig = opts?.killSignal ?? "SIGTERM"
proc, const send = (s: NodeJS.Signals) =>
command, Effect.catch(killGroup(command, proc, s), () => killOne(command, proc, s))
opts, const attempt = send(sig).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid)
)((command, proc, signal) => if (!opts?.forceKillAfter) return attempt
Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)), return Effect.timeoutOrElse(attempt, {
).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid), duration: opts.forceKillAfter,
orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
})
},
}) })
} }
case "PipedCommand": { case "PipedCommand": {
@@ -0,0 +1,6 @@
import { ServiceMap } from "effect"
import type { InstanceContext } from "@/project/instance"
export const InstanceRef = ServiceMap.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
defaultValue: () => undefined,
})
+40 -5
View File
@@ -1,5 +1,7 @@
import { Effect, ScopedCache, Scope } from "effect" import { Effect, Fiber, ScopedCache, Scope, ServiceMap } from "effect"
import { Instance, type InstanceContext } from "@/project/instance" import { Instance, type InstanceContext } from "@/project/instance"
import { Context } from "@/util/context"
import { InstanceRef } from "./instance-ref"
import { registerDisposer } from "./instance-registry" import { registerDisposer } from "./instance-registry"
const TypeId = "~opencode/InstanceState" const TypeId = "~opencode/InstanceState"
@@ -10,13 +12,34 @@ export interface InstanceState<A, E = never, R = never> {
} }
export namespace InstanceState { export namespace InstanceState {
export const bind = <F extends (...args: any[]) => any>(fn: F): F => {
try {
return Instance.bind(fn)
} catch (err) {
if (!(err instanceof Context.NotFound)) throw err
}
const fiber = Fiber.getCurrent()
const ctx = fiber ? ServiceMap.getReferenceUnsafe(fiber.services, InstanceRef) : undefined
if (!ctx) return fn
return ((...args: any[]) => Instance.restore(ctx, () => fn(...args))) as F
}
export const context = Effect.gen(function* () {
return (yield* InstanceRef) ?? Instance.current
})
export const directory = Effect.map(context, (ctx) => ctx.directory)
export const make = <A, E = never, R = never>( export const make = <A, E = never, R = never>(
init: (ctx: InstanceContext) => Effect.Effect<A, E, R | Scope.Scope>, init: (ctx: InstanceContext) => Effect.Effect<A, E, R | Scope.Scope>,
): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> => ): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
Effect.gen(function* () { Effect.gen(function* () {
const cache = yield* ScopedCache.make<string, A, E, R>({ const cache = yield* ScopedCache.make<string, A, E, R>({
capacity: Number.POSITIVE_INFINITY, capacity: Number.POSITIVE_INFINITY,
lookup: () => init(Instance.current), lookup: () =>
Effect.gen(function* () {
return yield* init(yield* context)
}),
}) })
const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory))) const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
@@ -29,7 +52,9 @@ export namespace InstanceState {
}) })
export const get = <A, E, R>(self: InstanceState<A, E, R>) => export const get = <A, E, R>(self: InstanceState<A, E, R>) =>
Effect.suspend(() => ScopedCache.get(self.cache, Instance.directory)) Effect.gen(function* () {
return yield* ScopedCache.get(self.cache, yield* directory)
})
export const use = <A, E, R, B>(self: InstanceState<A, E, R>, select: (value: A) => B) => export const use = <A, E, R, B>(self: InstanceState<A, E, R>, select: (value: A) => B) =>
Effect.map(get(self), select) Effect.map(get(self), select)
@@ -40,8 +65,18 @@ export namespace InstanceState {
) => Effect.flatMap(get(self), select) ) => Effect.flatMap(get(self), select)
export const has = <A, E, R>(self: InstanceState<A, E, R>) => export const has = <A, E, R>(self: InstanceState<A, E, R>) =>
Effect.suspend(() => ScopedCache.has(self.cache, Instance.directory)) Effect.gen(function* () {
return yield* ScopedCache.has(self.cache, yield* directory)
})
export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) => export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
Effect.suspend(() => ScopedCache.invalidate(self.cache, Instance.directory)) Effect.gen(function* () {
return yield* ScopedCache.invalidate(self.cache, yield* directory)
})
/**
* Effect finalizers run on the fiber scheduler after the original async
* boundary, so ALS reads like Instance.directory can be gone by then.
*/
export const withALS = <T>(fn: () => T) => Effect.map(context, (ctx) => Instance.restore(ctx, fn))
} }
+19 -5
View File
@@ -1,19 +1,33 @@
import { Effect, Layer, ManagedRuntime } from "effect" import { Effect, Layer, ManagedRuntime } from "effect"
import * as ServiceMap from "effect/ServiceMap" import * as ServiceMap from "effect/ServiceMap"
import { Instance } from "@/project/instance"
import { Context } from "@/util/context"
import { InstanceRef } from "./instance-ref"
export const memoMap = Layer.makeMemoMapUnsafe() export const memoMap = Layer.makeMemoMapUnsafe()
function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
try {
const ctx = Instance.current
return Effect.provideService(effect, InstanceRef, ctx)
} catch (err) {
if (!(err instanceof Context.NotFound)) throw err
}
return effect
}
export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) { export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
const getRuntime = () => (rt ??= ManagedRuntime.make(layer, { memoMap })) const getRuntime = () => (rt ??= ManagedRuntime.make(layer, { memoMap }))
return { return {
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)), runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(attach(service.use(fn))),
runPromiseExit: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) => runPromiseExit: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
getRuntime().runPromiseExit(service.use(fn), options), getRuntime().runPromiseExit(attach(service.use(fn)), options),
runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) => runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
getRuntime().runPromise(service.use(fn), options), getRuntime().runPromise(attach(service.use(fn)), options),
runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(service.use(fn)), runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(attach(service.use(fn))),
runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runCallback(service.use(fn)), runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) =>
getRuntime().runCallback(attach(service.use(fn))),
} }
} }
+216
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,
}
}
}
+10 -17
View File
@@ -5,7 +5,6 @@ import { AppFileSystem } from "@/filesystem"
import { git } from "@/util/git" import { git } from "@/util/git"
import { Effect, Layer, ServiceMap } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import { formatPatch, structuredPatch } from "diff" import { formatPatch, structuredPatch } from "diff"
import fs from "fs"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import ignore from "ignore" import ignore from "ignore"
import path from "path" import path from "path"
@@ -359,26 +358,23 @@ export namespace File {
const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global" const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
const next: Entry = { files: [], dirs: [] } const next: Entry = { files: [], dirs: [] }
yield* Effect.promise(async () => {
if (isGlobalHome) { if (isGlobalHome) {
const dirs = new Set<string>() const dirs = new Set<string>()
const protectedNames = Protected.names() const protectedNames = Protected.names()
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"]) const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name) const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name) const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
const top = await fs.promises const top = yield* appFs.readDirectoryEntries(Instance.directory).pipe(Effect.orElseSucceed(() => []))
.readdir(Instance.directory, { withFileTypes: true })
.catch(() => [] as fs.Dirent[])
for (const entry of top) { for (const entry of top) {
if (!entry.isDirectory()) continue if (entry.type !== "directory") continue
if (shouldIgnoreName(entry.name)) continue if (shouldIgnoreName(entry.name)) continue
dirs.add(entry.name + "/") dirs.add(entry.name + "/")
const base = path.join(Instance.directory, entry.name) const base = path.join(Instance.directory, entry.name)
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[]) const children = yield* appFs.readDirectoryEntries(base).pipe(Effect.orElseSucceed(() => []))
for (const child of children) { for (const child of children) {
if (!child.isDirectory()) continue if (child.type !== "directory") continue
if (shouldIgnoreNested(child.name)) continue if (shouldIgnoreNested(child.name)) continue
dirs.add(entry.name + "/" + child.name + "/") dirs.add(entry.name + "/" + child.name + "/")
} }
@@ -386,8 +382,9 @@ export namespace File {
next.dirs = Array.from(dirs).toSorted() next.dirs = Array.from(dirs).toSorted()
} else { } else {
const files = yield* Effect.promise(() => Array.fromAsync(Ripgrep.files({ cwd: Instance.directory })))
const seen = new Set<string>() const seen = new Set<string>()
for await (const file of Ripgrep.files({ cwd: Instance.directory })) { for (const file of files) {
next.files.push(file) next.files.push(file)
let current = file let current = file
while (true) { while (true) {
@@ -401,7 +398,6 @@ export namespace File {
} }
} }
} }
})
const s = yield* InstanceState.get(state) const s = yield* InstanceState.get(state)
s.cache = next s.cache = next
@@ -541,7 +537,7 @@ export namespace File {
const exists = yield* appFs.existsSafe(full) const exists = yield* appFs.existsSafe(full)
if (!exists) return { type: "text" as const, content: "" } if (!exists) return { type: "text" as const, content: "" }
const mimeType = Filesystem.mimeType(full) const mimeType = AppFileSystem.mimeType(full)
const encode = knownText ? false : shouldEncode(mimeType) const encode = knownText ? false : shouldEncode(mimeType)
if (encode && !isImage(mimeType)) return { type: "binary" as const, content: "", mimeType } if (encode && !isImage(mimeType)) return { type: "binary" as const, content: "", mimeType }
@@ -636,22 +632,20 @@ export namespace File {
yield* ensure() yield* ensure()
const { cache } = yield* InstanceState.get(state) const { cache } = yield* InstanceState.get(state)
return yield* Effect.promise(async () => {
const query = input.query.trim() const query = input.query.trim()
const limit = input.limit ?? 100 const limit = input.limit ?? 100
const kind = input.type ?? (input.dirs === false ? "file" : "all") const kind = input.type ?? (input.dirs === false ? "file" : "all")
log.info("search", { query, kind }) log.info("search", { query, kind })
const result = cache
const preferHidden = query.startsWith(".") || query.includes("/.") const preferHidden = query.startsWith(".") || query.includes("/.")
if (!query) { if (!query) {
if (kind === "file") return result.files.slice(0, limit) if (kind === "file") return cache.files.slice(0, limit)
return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit) return sortHiddenLast(cache.dirs.toSorted(), preferHidden).slice(0, limit)
} }
const items = const items =
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs] kind === "file" ? cache.files : kind === "directory" ? cache.dirs : [...cache.files, ...cache.dirs]
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target) const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
@@ -660,7 +654,6 @@ export namespace File {
log.info("search", { query, kind, results: output.length }) log.info("search", { query, kind, results: output.length })
return output return output
}) })
})
log.info("init") log.info("init")
return Service.of({ init, status, read, list, search }) return Service.of({ init, status, read, list, search })
+16 -16
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 { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema" import type { SessionID } from "@/session/schema"
import { Filesystem } from "../util/filesystem"
import { Log } from "../util/log" import { Log } from "../util/log"
export namespace FileTime { export namespace FileTime {
@@ -12,21 +12,9 @@ export namespace FileTime {
export type Stamp = { export type Stamp = {
readonly read: Date readonly read: Date
readonly mtime: number | undefined readonly mtime: number | undefined
readonly ctime: number | undefined
readonly size: 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 session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
const value = reads.get(sessionID) const value = reads.get(sessionID)
if (value) return value if (value) return value
@@ -53,7 +41,17 @@ export namespace FileTime {
export const layer = Layer.effect( export const layer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK 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>( const state = yield* InstanceState.make<State>(
Effect.fn("FileTime.state")(() => Effect.fn("FileTime.state")(() =>
Effect.succeed({ 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`) if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
const next = yield* stamp(filepath) 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 if (!changed) return
throw new Error( throw new Error(
@@ -108,7 +106,9 @@ export namespace FileTime {
}), }),
).pipe(Layer.orDie) ).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) { export function read(sessionID: SessionID, file: string) {
return runPromise((s) => s.read(sessionID, file)) return runPromise((s) => s.read(sessionID, file))
+2 -1
View File
@@ -108,10 +108,11 @@ export namespace Format {
for (const item of yield* Effect.promise(() => getFormatter(ext))) { for (const item of yield* Effect.promise(() => getFormatter(ext))) {
log.info("running", { command: item.command }) log.info("running", { command: item.command })
const cmd = item.command.map((x) => x.replace("$FILE", filepath)) const cmd = item.command.map((x) => x.replace("$FILE", filepath))
const dir = yield* InstanceState.directory
const code = yield* spawner const code = yield* spawner
.spawn( .spawn(
ChildProcess.make(cmd[0]!, cmd.slice(1), { ChildProcess.make(cmd[0]!, cmd.slice(1), {
cwd: Instance.directory, cwd: dir,
env: item.environment, env: item.environment,
extendEnv: true, extendEnv: true,
}), }),
+3 -7
View File
@@ -9,11 +9,7 @@ import z from "zod"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { Flag } from "../flag/flag" import { Flag } from "../flag/flag"
import { Log } from "../util/log" import { Log } from "../util/log"
import { CHANNEL as channel, VERSION as version } from "./meta"
declare global {
const OPENCODE_VERSION: string
const OPENCODE_CHANNEL: string
}
import semver from "semver" import semver from "semver"
@@ -60,8 +56,8 @@ export namespace Installation {
}) })
export type Info = z.infer<typeof Info> export type Info = z.infer<typeof Info>
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local" export const VERSION = version
export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local" export const CHANNEL = channel
export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}` export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}`
export function isPreview() { export function isPreview() {
@@ -0,0 +1,7 @@
declare global {
const OPENCODE_VERSION: string
const OPENCODE_CHANNEL: string
}
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
+10 -10
View File
@@ -477,7 +477,7 @@ export namespace MCP {
}) })
} }
const cache = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("MCP.state")(function* () { Effect.fn("MCP.state")(function* () {
const cfg = yield* cfgSvc.get() const cfg = yield* cfgSvc.get()
const config = cfg.mcp ?? {} const config = cfg.mcp ?? {}
@@ -549,7 +549,7 @@ export namespace MCP {
} }
const status = Effect.fn("MCP.status")(function* () { const status = Effect.fn("MCP.status")(function* () {
const s = yield* InstanceState.get(cache) const s = yield* InstanceState.get(state)
const cfg = yield* cfgSvc.get() const cfg = yield* cfgSvc.get()
const config = cfg.mcp ?? {} const config = cfg.mcp ?? {}
@@ -564,12 +564,12 @@ export namespace MCP {
}) })
const clients = Effect.fn("MCP.clients")(function* () { const clients = Effect.fn("MCP.clients")(function* () {
const s = yield* InstanceState.get(cache) const s = yield* InstanceState.get(state)
return s.clients return s.clients
}) })
const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: Config.Mcp) { const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: Config.Mcp) {
const s = yield* InstanceState.get(cache) const s = yield* InstanceState.get(state)
const result = yield* create(name, mcp) const result = yield* create(name, mcp)
s.status[name] = result.status s.status[name] = result.status
@@ -588,7 +588,7 @@ export namespace MCP {
const add = Effect.fn("MCP.add")(function* (name: string, mcp: Config.Mcp) { const add = Effect.fn("MCP.add")(function* (name: string, mcp: Config.Mcp) {
yield* createAndStore(name, mcp) yield* createAndStore(name, mcp)
const s = yield* InstanceState.get(cache) const s = yield* InstanceState.get(state)
return { status: s.status } return { status: s.status }
}) })
@@ -602,7 +602,7 @@ export namespace MCP {
}) })
const disconnect = Effect.fn("MCP.disconnect")(function* (name: string) { const disconnect = Effect.fn("MCP.disconnect")(function* (name: string) {
const s = yield* InstanceState.get(cache) const s = yield* InstanceState.get(state)
yield* closeClient(s, name) yield* closeClient(s, name)
delete s.clients[name] delete s.clients[name]
s.status[name] = { status: "disabled" } s.status[name] = { status: "disabled" }
@@ -610,7 +610,7 @@ export namespace MCP {
const tools = Effect.fn("MCP.tools")(function* () { const tools = Effect.fn("MCP.tools")(function* () {
const result: Record<string, Tool> = {} const result: Record<string, Tool> = {}
const s = yield* InstanceState.get(cache) const s = yield* InstanceState.get(state)
const cfg = yield* cfgSvc.get() const cfg = yield* cfgSvc.get()
const config = cfg.mcp ?? {} const config = cfg.mcp ?? {}
@@ -657,12 +657,12 @@ export namespace MCP {
} }
const prompts = Effect.fn("MCP.prompts")(function* () { const prompts = Effect.fn("MCP.prompts")(function* () {
const s = yield* InstanceState.get(cache) const s = yield* InstanceState.get(state)
return yield* collectFromConnected(s, (c) => c.listPrompts().then((r) => r.prompts), "prompts") return yield* collectFromConnected(s, (c) => c.listPrompts().then((r) => r.prompts), "prompts")
}) })
const resources = Effect.fn("MCP.resources")(function* () { const resources = Effect.fn("MCP.resources")(function* () {
const s = yield* InstanceState.get(cache) const s = yield* InstanceState.get(state)
return yield* collectFromConnected(s, (c) => c.listResources().then((r) => r.resources), "resources") return yield* collectFromConnected(s, (c) => c.listResources().then((r) => r.resources), "resources")
}) })
@@ -672,7 +672,7 @@ export namespace MCP {
label: string, label: string,
meta?: Record<string, unknown>, meta?: Record<string, unknown>,
) { ) {
const s = yield* InstanceState.get(cache) const s = yield* InstanceState.get(state)
const client = s.clients[clientName] const client = s.clients[clientName]
if (!client) { if (!client) {
log.warn(`client not found for ${label}`, { clientName }) log.warn(`client not found for ${label}`, { clientName })
-32
View File
@@ -375,38 +375,6 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
delete provider.models[modelId] 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) // Zero out costs for Codex (included with ChatGPT subscription)
for (const model of Object.values(provider.models)) { for (const model of Object.values(provider.models)) {
model.cost = { model.cost = {
+98 -107
View File
@@ -14,19 +14,8 @@ import { Effect, Layer, ServiceMap, Stream } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { errorMessage } from "@/util/error" import { errorMessage } from "@/util/error"
import { Installation } from "@/installation" import { PluginLoader } from "./loader"
import { import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
checkPluginCompatibility,
isDeprecatedPlugin,
parsePluginSpecifier,
pluginSource,
readPluginId,
readV1Plugin,
resolvePluginEntrypoint,
resolvePluginId,
resolvePluginTarget,
type PluginSource,
} from "./shared"
export namespace Plugin { export namespace Plugin {
const log = Log.create({ service: "plugin" }) const log = Log.create({ service: "plugin" })
@@ -36,11 +25,7 @@ export namespace Plugin {
} }
type Loaded = { type Loaded = {
item: Config.PluginSpec row: PluginLoader.Loaded
spec: string
target: string
source: PluginSource
mod: Record<string, unknown>
} }
// Hook names that follow the (input, output) => Promise<void> trigger pattern // Hook names that follow the (input, output) => Promise<void> trigger pattern
@@ -93,91 +78,22 @@ export namespace Plugin {
return result 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[]) { 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) { if (plugin) {
await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec)) await resolvePluginId(
hooks.push(await (plugin as PluginModule).server(input, Config.pluginOptions(load.item))) 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 return
} }
for (const server of getLegacyPlugins(load.mod)) { for (const server of getLegacyPlugins(load.row.mod)) {
hooks.push(await server(input, Config.pluginOptions(load.item))) hooks.push(await server(input, load.row.options))
} }
} }
@@ -187,7 +103,7 @@ export namespace Plugin {
const bus = yield* Bus.Service const bus = yield* Bus.Service
const config = yield* Config.Service const config = yield* Config.Service
const cache = yield* InstanceState.make<State>( const state = yield* InstanceState.make<State>(
Effect.fn("Plugin.state")(function* (ctx) { Effect.fn("Plugin.state")(function* (ctx) {
const hooks: Hooks[] = [] const hooks: Hooks[] = []
@@ -232,7 +148,82 @@ export namespace Plugin {
} }
if (plugins.length) yield* config.waitForDependencies() 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) { for (const load of loaded) {
if (!load) continue if (!load) continue
@@ -242,14 +233,14 @@ export namespace Plugin {
try: () => applyPlugin(load, input, hooks), try: () => applyPlugin(load, input, hooks),
catch: (err) => { catch: (err) => {
const message = errorMessage(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 return message
}, },
}).pipe( }).pipe(
Effect.catch((message) => Effect.catch((message) =>
bus.publish(Session.Event.Error, { bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({ error: new NamedError.Unknown({
message: `Failed to load plugin ${load.spec}: ${message}`, message: `Failed to load plugin ${load.row.spec}: ${message}`,
}).toObject(), }).toObject(),
}), }),
), ),
@@ -288,8 +279,8 @@ export namespace Plugin {
Output = Parameters<Required<Hooks>[Name]>[1], Output = Parameters<Required<Hooks>[Name]>[1],
>(name: Name, input: Input, output: Output) { >(name: Name, input: Input, output: Output) {
if (!name) return output if (!name) return output
const state = yield* InstanceState.get(cache) const s = yield* InstanceState.get(state)
for (const hook of state.hooks) { for (const hook of s.hooks) {
const fn = hook[name] as any const fn = hook[name] as any
if (!fn) continue if (!fn) continue
yield* Effect.promise(async () => fn(input, output)) yield* Effect.promise(async () => fn(input, output))
@@ -298,12 +289,12 @@ export namespace Plugin {
}) })
const list = Effect.fn("Plugin.list")(function* () { const list = Effect.fn("Plugin.list")(function* () {
const state = yield* InstanceState.get(cache) const s = yield* InstanceState.get(state)
return state.hooks return s.hooks
}) })
const init = Effect.fn("Plugin.init")(function* () { const init = Effect.fn("Plugin.init")(function* () {
yield* InstanceState.get(cache) yield* InstanceState.get(state)
}) })
return Service.of({ trigger, list, init }) return Service.of({ trigger, list, init })
+112 -46
View File
@@ -11,6 +11,7 @@ import { ConfigPaths } from "@/config/paths"
import { Global } from "@/global" import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { Flock } from "@/util/flock" import { Flock } from "@/util/flock"
import { isRecord } from "@/util/record"
import { parsePluginSpecifier, readPluginPackage, resolvePluginTarget } from "./shared" import { parsePluginSpecifier, readPluginPackage, resolvePluginTarget } from "./shared"
@@ -94,33 +95,91 @@ function pluginSpec(item: unknown) {
return item[0] return item[0]
} }
function parseTarget(item: unknown): Target | undefined { function pluginList(data: unknown) {
if (item === "server" || item === "tui") return { kind: item } if (!data || typeof data !== "object" || Array.isArray(data)) return
if (!Array.isArray(item)) return const item = data as { plugin?: unknown }
if (item[0] !== "server" && item[0] !== "tui") return if (!Array.isArray(item.plugin)) return
if (item.length < 2) return { kind: item[0] } return item.plugin
const opt = item[1]
if (!opt || typeof opt !== "object" || Array.isArray(opt)) return { kind: item[0] }
return {
kind: item[0],
opts: opt,
}
} }
function parseTargets(raw: unknown) { function exportValue(value: unknown): string | undefined {
if (!Array.isArray(raw)) return [] if (typeof value === "string") {
const map = new Map<Kind, Target>() const next = value.trim()
for (const item of raw) { if (next) return next
const hit = parseTarget(item) 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 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 pkg = parsePluginSpecifier(spec).pkg
const rows = list.map((item, i) => ({ const rows = (list ?? []).map((item, i) => ({
item, item,
i, i,
spec: pluginSpec(item), spec: pluginSpec(item),
@@ -133,16 +192,22 @@ function patchPluginList(list: unknown[], spec: string, next: unknown, force = f
}) })
if (!dup.length) { if (!dup.length) {
if (!list) {
return { return {
mode: "add", mode: "add",
list: [...list, next], text: patch(text, ["plugin"], [next]),
}
}
return {
mode: "add",
text: patch(text, ["plugin", list.length], next, true),
} }
} }
if (!force) { if (!force) {
return { return {
mode: "noop", mode: "noop",
list, text,
} }
} }
@@ -150,29 +215,37 @@ function patchPluginList(list: unknown[], spec: string, next: unknown, force = f
if (!keep) { if (!keep) {
return { return {
mode: "noop", mode: "noop",
list, text,
} }
} }
if (dup.length === 1 && keep.spec === spec) { if (dup.length === 1 && keep.spec === spec) {
return { return {
mode: "noop", 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 { return {
mode: "replace", mode: "replace",
list: rows.flatMap((row) => { text: out,
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]
}),
} }
} }
@@ -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) { if (!targets.length) {
return { return {
ok: false, ok: false,
@@ -289,10 +362,9 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea
} }
} }
const list: unknown[] = const list = pluginList(data)
data && typeof data === "object" && !Array.isArray(data) && Array.isArray(data.plugin) ? data.plugin : [] const item = target.opts ? ([spec, target.opts] as const) : spec
const item = target.opts ? [spec, target.opts] : spec const out = patchPluginList(text, list, spec, item, force)
const out = patchPluginList(list, spec, item, force)
if (out.mode === "noop") { if (out.mode === "noop") {
return { return {
ok: true, ok: true,
@@ -304,13 +376,7 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea
} }
} }
const edits = modify(text, ["plugin"], out.list, { const write = await dep.write(cfg, out.text).catch((error: unknown) => error)
formattingOptions: {
tabSize: 2,
insertSpaces: true,
},
})
const write = await dep.write(cfg, applyEdits(text, edits)).catch((error: unknown) => error)
if (write instanceof Error) { if (write instanceof Error) {
return { return {
ok: false, ok: false,
+137
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,
},
}
}
}

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