Compare commits
96
Commits
v1.3.5
...
effect/summary
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
839efafad6 | ||
|
|
c619caefdd | ||
|
|
c559af51ce | ||
|
|
d1e0a4640c | ||
|
|
f9e71ec515 | ||
|
|
ef538c9707 | ||
|
|
2f405daa98 | ||
|
|
a9c85b7c27 | ||
|
|
897d83c589 | ||
|
|
0a125e5d4d | ||
|
|
38d2276592 | ||
|
|
d58004a864 | ||
|
|
5fd833aa18 | ||
|
|
44f83015cd | ||
|
|
9a1c9ae15a | ||
|
|
a3a6cf1c07 | ||
|
|
47a676111a | ||
|
|
1df5ad470a | ||
|
|
506dd75818 | ||
|
|
c8ecd64022 | ||
|
|
ca376a4cff | ||
|
|
7532d99e5b | ||
|
|
181b5f6236 | ||
|
|
6314f09c14 | ||
|
|
4b4b7832aa | ||
|
|
4280307013 | ||
|
|
9b09a7e766 | ||
|
|
3fc0367b93 | ||
|
|
954a6ca88e | ||
|
|
0c03a3ee10 | ||
|
|
53330a518f | ||
|
|
892bdebaac | ||
|
|
18121300f3 | ||
|
|
d6d4446f46 | ||
|
|
26cc924ea2 | ||
|
|
4dd866d5c4 | ||
|
|
beab4cc2c2 | ||
|
|
567a91191a | ||
|
|
434d82bbe2 | ||
|
|
2929774acb | ||
|
|
6e61a46a84 | ||
|
|
2daf4b805a | ||
|
|
7342e650c0 | ||
|
|
8c2e2ecc95 | ||
|
|
25a2b739e6 | ||
|
|
85c16926c4 | ||
|
|
2e78fdec43 | ||
|
|
1fcb920eb4 | ||
|
|
b1e89c344b | ||
|
|
befbedacdc | ||
|
|
2cc738fb17 | ||
|
|
71b20698bb | ||
|
|
3df18dcde1 | ||
|
|
a898c2ea3a | ||
|
|
bf777298c8 | ||
|
|
93fad99f7f | ||
|
|
057848deb8 | ||
|
|
1de06452d3 | ||
|
|
58f60629a1 | ||
|
|
39a47c9b8c | ||
|
|
ea88044f2e | ||
|
|
e6f6f7aff1 | ||
|
|
48e97b47af | ||
|
|
fe120e3cbf | ||
|
|
f2dd774660 | ||
|
|
e7ff0f17c8 | ||
|
|
2ed756c72c | ||
|
|
054f4be185 | ||
|
|
e3e1e9af50 | ||
|
|
c8389cf96d | ||
|
|
c5442d418d | ||
|
|
fa95a61c4e | ||
|
|
9f3c2bd861 | ||
|
|
c2f78224ae | ||
|
|
14f9e21d5c | ||
|
|
8e4bab5181 | ||
|
|
3c32013eb1 | ||
|
|
47d2ab120a | ||
|
|
186af2723d | ||
|
|
6926fe1c74 | ||
|
|
ee018d5c82 | ||
|
|
0465579d6b | ||
|
|
196a03caff | ||
|
|
b234370080 | ||
|
|
5d2dc8888c | ||
|
|
0b1018f6dd | ||
|
|
afb6abff73 | ||
|
|
e7f94f9b9a | ||
|
|
72c77d0e7b | ||
|
|
5c15755a10 | ||
|
|
3a4bfeb5b5 | ||
|
|
1037c72d99 | ||
|
|
ba00e9a993 | ||
|
|
963dad75ef | ||
|
|
7e9b721e97 | ||
|
|
a5b1dc081d |
+4
-1
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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 }
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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,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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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": {
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
|||||||
@@ -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,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,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,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",
|
||||||
|
|||||||
@@ -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,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,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",
|
||||||
|
|||||||
@@ -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,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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user