mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-17 12:56:41 +02:00
Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2fcdef571 | |||
| 8137e4dd9c | |||
| 7be6671e6e | |||
| 575cc59b37 | |||
| 4350b8fd6b | |||
| 2111473746 | |||
| 8c5c377680 | |||
| d51089b52f | |||
| 694695050a | |||
| 1f3b2b5951 | |||
| de87694867 | |||
| ecd28fd520 | |||
| bf9047ccd1 | |||
| 96a9744347 | |||
| c4594c4c1f | |||
| eea70be21a | |||
| e83c01ad36 | |||
| 7eb724f4e9 | |||
| 7653e2d4d8 | |||
| 261b1eca2e | |||
| dbc15d4816 | |||
| 602b6be4d4 | |||
| 7f4277695d | |||
| 39afc055bf | |||
| 73b1bc42f4 | |||
| 3eea1d424e | |||
| 1092cf4034 | |||
| 0c270b4743 | |||
| 715860f997 | |||
| 26f66b5f5d | |||
| f250a229c9 | |||
| c2c2bb1fa9 | |||
| 19ac6f1948 | |||
| 0d9ce6ad7b | |||
| 2b95956132 | |||
| 36bbe809fa | |||
| a8113ee0df | |||
| bcb8d970f1 | |||
| c57491ba48 | |||
| 9ffb7141e5 | |||
| f3b0f312bf | |||
| 09a6107649 | |||
| af8d91117c | |||
| 0ffc2c2b39 | |||
| f9c951aa8b | |||
| 78bcbda2fa | |||
| 262aca1bca | |||
| 0cbbe5af77 | |||
| ecae24f426 | |||
| 745206ffbb | |||
| 83557e9b66 | |||
| 1a4abe85e8 | |||
| 175313922b | |||
| 74ad6dd4c9 | |||
| a94667e8e7 | |||
| ff8abd8c2b | |||
| 95e9407e63 | |||
| 1466b43c5c | |||
| f73d7e67d3 | |||
| 1ac0980c80 | |||
| 1d6f650f53 | |||
| 0b9b85ea6e | |||
| 5521d66bb8 | |||
| 281c9d1870 | |||
| 80481c2247 | |||
| 156ce54362 | |||
| 1bc919dc74 | |||
| 17438a2e90 | |||
| 17c4202ea8 | |||
| 7170983ef2 | |||
| b05d88a730 | |||
| a3a06ffc4f | |||
| 68e41a1ee7 | |||
| 5622c53e1f | |||
| dfe6ce211d | |||
| 8639b0767a | |||
| 5f67e6fd12 | |||
| 86b2002deb | |||
| 7f862533d8 | |||
| 8f62d4a5e3 | |||
| 733226de9d | |||
| e8b0a65c63 | |||
| cd2125eecd | |||
| 8595dae1a4 | |||
| c365f0a7c1 | |||
| 01b12949e3 | |||
| ac7e674a87 | |||
| 47fa496701 | |||
| d77cbf9c46 | |||
| 340285575b | |||
| dd5b5f5482 | |||
| 924fc9ed80 | |||
| df094a10ff | |||
| de3641e8eb | |||
| 8bcbfd6396 | |||
| e521fee002 | |||
| 04e60f2b3d | |||
| 23d71e125c | |||
| 27406cf8ef | |||
| 0596b02f19 | |||
| 5145b72c4a | |||
| 347cd8ac63 | |||
| b711ca57f2 | |||
| 353115a895 | |||
| 5f0372183a | |||
| 616329ae97 | |||
| 9706aaf552 | |||
| 8b379329a6 | |||
| 68d1755a9e | |||
| 419004992d | |||
| 0d49df46ef | |||
| 36f5ba52e9 | |||
| 088b537657 | |||
| 4ddfa86e7f | |||
| b91b76e9eb | |||
| 6ed656a615 | |||
| 7b336add88 | |||
| 7f9ffe57f9 | |||
| ad31b555a8 | |||
| a05c334702 | |||
| cf284e32aa | |||
| 054ccee78d | |||
| bfa986d45e | |||
| aa4b06e165 | |||
| 2542693f7b | |||
| bec294b781 |
+66
-26
@@ -8,7 +8,29 @@ on:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
test:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
name: test (${{ matrix.settings.name }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
settings:
|
||||
- name: linux
|
||||
host: blacksmith-4vcpu-ubuntu-2404
|
||||
playwright: bunx playwright install --with-deps
|
||||
workdir: .
|
||||
command: |
|
||||
git config --global user.email "bot@opencode.ai"
|
||||
git config --global user.name "opencode"
|
||||
bun turbo typecheck
|
||||
bun turbo test
|
||||
- name: windows
|
||||
host: windows-latest
|
||||
playwright: bunx playwright install
|
||||
workdir: packages/app
|
||||
command: bun test:e2e
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -20,42 +42,63 @@ jobs:
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: packages/app
|
||||
run: bunx playwright install --with-deps
|
||||
run: ${{ matrix.settings.playwright }}
|
||||
|
||||
- name: Set OS-specific paths
|
||||
run: |
|
||||
if [ "${{ runner.os }}" = "Windows" ]; then
|
||||
printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}\\opencode-e2e" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}\\opencode-e2e\\home" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}\\opencode-e2e\\share" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}\\opencode-e2e\\cache" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}\\opencode-e2e\\config" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}\\opencode-e2e\\state" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "MODELS_DEV_API_JSON=${{ github.workspace }}\\packages\\opencode\\test\\tool\\fixtures\\models-api.json" >> "$GITHUB_ENV"
|
||||
else
|
||||
printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}/opencode-e2e" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}/opencode-e2e/home" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}/opencode-e2e/share" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}/opencode-e2e/cache" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}/opencode-e2e/config" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}/opencode-e2e/state" >> "$GITHUB_ENV"
|
||||
printf '%s\n' "MODELS_DEV_API_JSON=${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Seed opencode data
|
||||
working-directory: packages/opencode
|
||||
run: bun script/seed-e2e.ts
|
||||
env:
|
||||
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json
|
||||
MODELS_DEV_API_JSON: ${{ env.MODELS_DEV_API_JSON }}
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "true"
|
||||
OPENCODE_DISABLE_SHARE: "true"
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
|
||||
OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home
|
||||
XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share
|
||||
XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache
|
||||
XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config
|
||||
XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state
|
||||
OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
|
||||
XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
|
||||
XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
|
||||
XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
|
||||
XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
|
||||
OPENCODE_E2E_PROJECT_DIR: ${{ github.workspace }}
|
||||
OPENCODE_E2E_SESSION_TITLE: "E2E Session"
|
||||
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e"
|
||||
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano"
|
||||
|
||||
- name: Run opencode server
|
||||
run: bun run dev -- --print-logs --log-level WARN serve --port 4096 --hostname 0.0.0.0 &
|
||||
working-directory: packages/opencode
|
||||
run: bun dev -- --print-logs --log-level WARN serve --port 4096 --hostname 0.0.0.0 &
|
||||
env:
|
||||
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json
|
||||
MODELS_DEV_API_JSON: ${{ env.MODELS_DEV_API_JSON }}
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "true"
|
||||
OPENCODE_DISABLE_SHARE: "true"
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
|
||||
OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home
|
||||
XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share
|
||||
XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache
|
||||
XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config
|
||||
XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state
|
||||
OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
|
||||
XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
|
||||
XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
|
||||
XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
|
||||
XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
|
||||
OPENCODE_CLIENT: "app"
|
||||
|
||||
- name: Wait for opencode server
|
||||
@@ -67,24 +110,21 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: run
|
||||
run: |
|
||||
git config --global user.email "bot@opencode.ai"
|
||||
git config --global user.name "opencode"
|
||||
bun turbo typecheck
|
||||
bun turbo test
|
||||
working-directory: ${{ matrix.settings.workdir }}
|
||||
run: ${{ matrix.settings.command }}
|
||||
env:
|
||||
CI: true
|
||||
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json
|
||||
MODELS_DEV_API_JSON: ${{ env.MODELS_DEV_API_JSON }}
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "true"
|
||||
OPENCODE_DISABLE_SHARE: "true"
|
||||
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
|
||||
OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home
|
||||
XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share
|
||||
XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache
|
||||
XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config
|
||||
XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state
|
||||
OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
|
||||
XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
|
||||
XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
|
||||
XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
|
||||
XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
|
||||
PLAYWRIGHT_SERVER_HOST: "localhost"
|
||||
PLAYWRIGHT_SERVER_PORT: "4096"
|
||||
VITE_OPENCODE_SERVER_HOST: "localhost"
|
||||
|
||||
@@ -128,7 +128,7 @@ jobs:
|
||||
echo "Changes detected:"
|
||||
echo "$STATUS"
|
||||
git add "${FILES[@]}"
|
||||
git commit -m "Update $TITLE"
|
||||
git commit -m "chore: update nix node_modules hashes"
|
||||
|
||||
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
|
||||
git pull --rebase --autostash origin "$BRANCH"
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
name: bun-file-io
|
||||
description: Use this when you are working on file operations like reading, writing, scanning, or deleting files. It summarizes the preferred file APIs and patterns used in this repo. It also notes when to use filesystem helpers for directories.
|
||||
---
|
||||
|
||||
## Use this when
|
||||
|
||||
- Editing file I/O or scans in `packages/opencode`
|
||||
- Handling directory operations or external tools
|
||||
|
||||
## Bun file APIs (from Bun docs)
|
||||
|
||||
- `Bun.file(path)` is lazy; call `text`, `json`, `stream`, `arrayBuffer`, `bytes`, `exists` to read.
|
||||
- Metadata: `file.size`, `file.type`, `file.name`.
|
||||
- `Bun.write(dest, input)` writes strings, buffers, Blobs, Responses, or files.
|
||||
- `Bun.file(...).delete()` deletes a file.
|
||||
- `file.writer()` returns a FileSink for incremental writes.
|
||||
- `Bun.Glob` + `Array.fromAsync(glob.scan({ cwd, absolute, onlyFiles, dot }))` for scans.
|
||||
- Use `Bun.which` to find a binary, then `Bun.spawn` to run it.
|
||||
- `Bun.readableStreamToText/Bytes/JSON` for stream output.
|
||||
|
||||
## When to use node:fs
|
||||
|
||||
- Use `node:fs/promises` for directories (`mkdir`, `readdir`, recursive operations).
|
||||
|
||||
## Repo patterns
|
||||
|
||||
- Prefer Bun APIs over Node `fs` for file access.
|
||||
- Check `Bun.file(...).exists()` before reading.
|
||||
- For binary/large files use `arrayBuffer()` and MIME checks via `file.type`.
|
||||
- Use `Bun.Glob` + `Array.fromAsync` for scans.
|
||||
- Decode tool stderr with `Bun.readableStreamToText`.
|
||||
- For large writes, use `Bun.write(Bun.file(path), text)`.
|
||||
|
||||
## Quick checklist
|
||||
|
||||
- Use Bun APIs first.
|
||||
- Use `path.join`/`path.resolve` for paths.
|
||||
- Prefer promise `.catch(...)` over `try/catch` when possible.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
name: test-skill
|
||||
description: use this when asked to test skill
|
||||
---
|
||||
|
||||
woah this is a test skill
|
||||
@@ -24,6 +24,7 @@ Server mode is opt-in only. When enabled, set `OPENCODE_SERVER_PASSWORD` to requ
|
||||
| **Sandbox escapes** | The permission system is not a sandbox (see above) |
|
||||
| **LLM provider data handling** | Data sent to your configured LLM provider is governed by their policies |
|
||||
| **MCP server behavior** | External MCP servers you configure are outside our trust boundary |
|
||||
| **Malicious config files** | Users control their own config; modifying it is not an attack vector |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -205,3 +205,4 @@
|
||||
| 2026-01-17 | 4,389,558 (+268,008) | 1,805,315 (+50,897) | 6,194,873 (+318,905) |
|
||||
| 2026-01-18 | 4,627,623 (+238,065) | 1,839,171 (+33,856) | 6,466,794 (+271,921) |
|
||||
| 2026-01-19 | 4,861,108 (+233,485) | 1,863,112 (+23,941) | 6,724,220 (+257,426) |
|
||||
| 2026-01-20 | 5,128,999 (+267,891) | 1,903,665 (+40,553) | 7,032,664 (+308,444) |
|
||||
|
||||
@@ -16,13 +16,14 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"husky": "9.1.7",
|
||||
"prettier": "3.6.2",
|
||||
"semver": "^7.6.0",
|
||||
"sst": "3.17.23",
|
||||
"turbo": "2.5.6",
|
||||
},
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -71,7 +72,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -105,7 +106,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -132,7 +133,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -156,7 +157,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -180,7 +181,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -209,7 +210,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -238,7 +239,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -254,7 +255,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -358,7 +359,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -374,11 +375,12 @@
|
||||
"name": "@opencode-ai/script",
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
},
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.4",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -389,7 +391,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -402,7 +404,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -443,7 +445,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -454,7 +456,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -514,6 +516,7 @@
|
||||
"@types/bun": "1.3.5",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "22.13.9",
|
||||
"@types/semver": "7.7.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
"ai": "5.0.119",
|
||||
"diff": "8.0.2",
|
||||
@@ -1849,6 +1852,8 @@
|
||||
|
||||
"@types/scheduler": ["@types/scheduler@0.26.0", "", {}, "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA=="],
|
||||
|
||||
"@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="],
|
||||
|
||||
"@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="],
|
||||
|
||||
"@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-80+b7FwUy4mRWTzEjPrBWuR5Um67I1Rn4U/n/s/lBjs=",
|
||||
"aarch64-linux": "sha256-xH/Grwh3b+HWsUkKN8LMcyMaMcmnIJYlgp38WJCat5E=",
|
||||
"aarch64-darwin": "sha256-Izv6PE9gNaeYYfcqDwjTU/WYtD1y+j65annwvLzkMD8=",
|
||||
"x86_64-darwin": "sha256-EG1Z0uAeyFiOeVsv0Sz1sa8/mdXuw/uvbYYrkFR3EAg="
|
||||
"x86_64-linux": "sha256-xVA4r7Qugw0TSx5wiTI5al93FI4D5LlvQo2ab3cUlmE=",
|
||||
"aarch64-linux": "sha256-EV0U/mXlrnEyCryL9rLlOZvMn6U0+BSgPhTIudVeqTo=",
|
||||
"aarch64-darwin": "sha256-zQvdRyNEHrpJsQMj8PZH0Ub21EREmDetVaJ0yBCgDlE=",
|
||||
"x86_64-darwin": "sha256-Tt5k5KBnrsNVIqPET7OFzClerjdR68XYstyCj3KpvdI="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/node": "22.13.9",
|
||||
"@types/semver": "7.7.1",
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
@@ -66,6 +67,7 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"husky": "9.1.7",
|
||||
"prettier": "3.6.2",
|
||||
"semver": "^7.6.0",
|
||||
"sst": "3.17.23",
|
||||
"turbo": "2.5.6"
|
||||
},
|
||||
|
||||
+11
-2
@@ -1,7 +1,5 @@
|
||||
## Debugging
|
||||
|
||||
- To test the opencode app, use the playwright MCP server, the app is already
|
||||
running at http://localhost:3000
|
||||
- NEVER try to restart the app, or the server process, EVER.
|
||||
|
||||
## SolidJS
|
||||
@@ -11,3 +9,14 @@
|
||||
## Tool Calling
|
||||
|
||||
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
|
||||
|
||||
## Browser Automation
|
||||
|
||||
Use `agent-browser` for web automation. Run `agent-browser --help` for all commands.
|
||||
|
||||
Core workflow:
|
||||
|
||||
1. `agent-browser open <url>` - Navigate to page
|
||||
2. `agent-browser snapshot -i` - Get interactive elements with refs (@e1, @e2)
|
||||
3. `agent-browser click @e1` / `fill @e2 "text"` - Interact using refs
|
||||
4. Re-snapshot after page changes
|
||||
|
||||
@@ -31,18 +31,20 @@ Your app is ready to be deployed!
|
||||
|
||||
## E2E Testing
|
||||
|
||||
The Playwright runner expects the app already running at `http://localhost:3000`.
|
||||
Playwright starts the Vite dev server automatically via `webServer`, and UI tests need an opencode backend (defaults to `localhost:4096`).
|
||||
Use the local runner to create a temp sandbox, seed data, and run the tests.
|
||||
|
||||
```bash
|
||||
bun add -D @playwright/test
|
||||
bunx playwright install
|
||||
bun run test:e2e
|
||||
bun run test:e2e:local
|
||||
bun run test:e2e:local -- --grep "settings"
|
||||
```
|
||||
|
||||
Environment options:
|
||||
|
||||
- `PLAYWRIGHT_BASE_URL` (default: `http://localhost:3000`)
|
||||
- `PLAYWRIGHT_PORT` (default: `3000`)
|
||||
- `PLAYWRIGHT_SERVER_HOST` / `PLAYWRIGHT_SERVER_PORT` (backend address, default: `localhost:4096`)
|
||||
- `PLAYWRIGHT_PORT` (Vite dev server port, default: `3000`)
|
||||
- `PLAYWRIGHT_BASE_URL` (override base URL, default: `http://localhost:<PLAYWRIGHT_PORT>`)
|
||||
|
||||
## Deployment
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { test, expect } from "./fixtures"
|
||||
import { promptSelector } from "./utils"
|
||||
|
||||
function sessionIDFromUrl(url: string) {
|
||||
const match = /\/session\/([^/?#]+)/.exec(url)
|
||||
return match?.[1]
|
||||
}
|
||||
|
||||
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const pageErrors: string[] = []
|
||||
const onPageError = (err: Error) => {
|
||||
pageErrors.push(err.message)
|
||||
}
|
||||
page.on("pageerror", onPageError)
|
||||
|
||||
await gotoSession()
|
||||
|
||||
const token = `E2E_OK_${Date.now()}`
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
await prompt.click()
|
||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||
|
||||
const sessionID = (() => {
|
||||
const id = sessionIDFromUrl(page.url())
|
||||
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||
return id
|
||||
})()
|
||||
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
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")
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
|
||||
.toContain(token)
|
||||
|
||||
const reply = page.locator('[data-slot="session-turn-summary-section"]').filter({ hasText: token }).first()
|
||||
await expect(reply).toBeVisible({ timeout: 90_000 })
|
||||
} finally {
|
||||
page.off("pageerror", onPageError)
|
||||
await sdk.session.delete({ sessionID }).catch(() => undefined)
|
||||
}
|
||||
|
||||
if (pageErrors.length > 0) {
|
||||
throw new Error(`Page error(s):\n${pageErrors.join("\n")}`)
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -15,6 +15,7 @@
|
||||
"serve": "vite preview",
|
||||
"test": "playwright test",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:local": "bun script/e2e-local.ts",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:report": "playwright show-report e2e/playwright-report"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import fs from "node:fs/promises"
|
||||
import net from "node:net"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
async function freePort() {
|
||||
return await 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)
|
||||
return
|
||||
}
|
||||
resolve(address.port)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForHealth(url: string) {
|
||||
const timeout = Date.now() + 60_000
|
||||
while (Date.now() < timeout) {
|
||||
const ok = await fetch(url)
|
||||
.then((r) => r.ok)
|
||||
.catch(() => false)
|
||||
if (ok) return
|
||||
await new Promise((r) => setTimeout(r, 250))
|
||||
}
|
||||
throw new Error(`Timed out waiting for server health: ${url}`)
|
||||
}
|
||||
|
||||
const appDir = process.cwd()
|
||||
const repoDir = path.resolve(appDir, "../..")
|
||||
const opencodeDir = path.join(repoDir, "packages", "opencode")
|
||||
const modelsJson = path.join(opencodeDir, "test", "tool", "fixtures", "models-api.json")
|
||||
|
||||
const extraArgs = (() => {
|
||||
const args = process.argv.slice(2)
|
||||
if (args[0] === "--") return args.slice(1)
|
||||
return args
|
||||
})()
|
||||
|
||||
const [serverPort, webPort] = await Promise.all([freePort(), freePort()])
|
||||
|
||||
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-"))
|
||||
|
||||
const serverEnv = {
|
||||
...process.env,
|
||||
MODELS_DEV_API_JSON: modelsJson,
|
||||
OPENCODE_DISABLE_MODELS_FETCH: "true",
|
||||
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_E2E_PROJECT_DIR: repoDir,
|
||||
OPENCODE_E2E_SESSION_TITLE: "E2E Session",
|
||||
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
|
||||
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano",
|
||||
OPENCODE_CLIENT: "app",
|
||||
} satisfies Record<string, string>
|
||||
|
||||
const runnerEnv = {
|
||||
...process.env,
|
||||
PLAYWRIGHT_SERVER_HOST: "localhost",
|
||||
PLAYWRIGHT_SERVER_PORT: String(serverPort),
|
||||
VITE_OPENCODE_SERVER_HOST: "localhost",
|
||||
VITE_OPENCODE_SERVER_PORT: String(serverPort),
|
||||
PLAYWRIGHT_PORT: String(webPort),
|
||||
} satisfies Record<string, string>
|
||||
|
||||
const seed = Bun.spawn(["bun", "script/seed-e2e.ts"], {
|
||||
cwd: opencodeDir,
|
||||
env: serverEnv,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
|
||||
const seedExit = await seed.exited
|
||||
if (seedExit !== 0) {
|
||||
process.exit(seedExit)
|
||||
}
|
||||
|
||||
const server = Bun.spawn(
|
||||
[
|
||||
"bun",
|
||||
"dev",
|
||||
"--",
|
||||
"--print-logs",
|
||||
"--log-level",
|
||||
"WARN",
|
||||
"serve",
|
||||
"--port",
|
||||
String(serverPort),
|
||||
"--hostname",
|
||||
"127.0.0.1",
|
||||
],
|
||||
{
|
||||
cwd: opencodeDir,
|
||||
env: serverEnv,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
await waitForHealth(`http://localhost:${serverPort}/global/health`)
|
||||
|
||||
const runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], {
|
||||
cwd: appDir,
|
||||
env: runnerEnv,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
})
|
||||
|
||||
process.exitCode = await runner.exited
|
||||
} finally {
|
||||
server.kill()
|
||||
}
|
||||
+24
-19
@@ -14,6 +14,7 @@ import { PermissionProvider } from "@/context/permission"
|
||||
import { LayoutProvider } from "@/context/layout"
|
||||
import { GlobalSDKProvider } from "@/context/global-sdk"
|
||||
import { ServerProvider, useServer } from "@/context/server"
|
||||
import { SettingsProvider } from "@/context/settings"
|
||||
import { TerminalProvider } from "@/context/terminal"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { FileProvider } from "@/context/file"
|
||||
@@ -82,15 +83,17 @@ export function AppInterface(props: { defaultUrl?: string }) {
|
||||
<GlobalSyncProvider>
|
||||
<Router
|
||||
root={(props) => (
|
||||
<PermissionProvider>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider>
|
||||
<CommandProvider>
|
||||
<Layout>{props.children}</Layout>
|
||||
</CommandProvider>
|
||||
</NotificationProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
<SettingsProvider>
|
||||
<PermissionProvider>
|
||||
<LayoutProvider>
|
||||
<NotificationProvider>
|
||||
<CommandProvider>
|
||||
<Layout>{props.children}</Layout>
|
||||
</CommandProvider>
|
||||
</NotificationProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
</SettingsProvider>
|
||||
)}
|
||||
>
|
||||
<Route
|
||||
@@ -105,16 +108,18 @@ export function AppInterface(props: { defaultUrl?: string }) {
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
<Route
|
||||
path="/session/:id?"
|
||||
component={() => (
|
||||
<TerminalProvider>
|
||||
<FileProvider>
|
||||
<PromptProvider>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Session />
|
||||
</Suspense>
|
||||
</PromptProvider>
|
||||
</FileProvider>
|
||||
</TerminalProvider>
|
||||
component={(p) => (
|
||||
<Show when={p.params.id ?? "new"} keyed>
|
||||
<TerminalProvider>
|
||||
<FileProvider>
|
||||
<PromptProvider>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Session />
|
||||
</Suspense>
|
||||
</PromptProvider>
|
||||
</FileProvider>
|
||||
</TerminalProvider>
|
||||
</Show>
|
||||
)}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
@@ -82,7 +82,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
|
||||
return (
|
||||
<Dialog title="Edit project" class="w-full max-w-[480px] mx-auto">
|
||||
<form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6">
|
||||
<form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6 pt-0">
|
||||
<div class="flex flex-col gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
@@ -123,6 +123,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
fallback={store.name || defaultName()}
|
||||
{...getAvatarColors(store.color)}
|
||||
class="size-full"
|
||||
style={{ "font-size": "32px" }}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -34,7 +34,14 @@ export function DialogSelectFile() {
|
||||
const view = createMemo(() => layout.view(sessionKey()))
|
||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||
const [grouped, setGrouped] = createSignal(false)
|
||||
const common = ["session.new", "session.previous", "session.next", "terminal.toggle", "review.toggle"]
|
||||
const common = [
|
||||
"session.new",
|
||||
"workspace.new",
|
||||
"session.previous",
|
||||
"session.next",
|
||||
"terminal.toggle",
|
||||
"review.toggle",
|
||||
]
|
||||
const limit = 5
|
||||
|
||||
const allowed = createMemo(() =>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Component } from "solid-js"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { SettingsGeneral } from "./settings-general"
|
||||
import { SettingsKeybinds } from "./settings-keybinds"
|
||||
import { SettingsPermissions } from "./settings-permissions"
|
||||
import { SettingsProviders } from "./settings-providers"
|
||||
import { SettingsModels } from "./settings-models"
|
||||
import { SettingsAgents } from "./settings-agents"
|
||||
import { SettingsCommands } from "./settings-commands"
|
||||
import { SettingsMcp } from "./settings-mcp"
|
||||
|
||||
export const DialogSettings: Component = () => {
|
||||
return (
|
||||
<Dialog size="x-large">
|
||||
<Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
|
||||
<Tabs.List>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"flex-direction": "column",
|
||||
gap: "12px",
|
||||
width: "100%",
|
||||
"padding-top": "12px",
|
||||
"padding-bottom": "12px",
|
||||
}}
|
||||
>
|
||||
<Tabs.SectionTitle>Desktop</Tabs.SectionTitle>
|
||||
<div style={{ display: "flex", "flex-direction": "column", gap: "6px", width: "100%" }}>
|
||||
<Tabs.Trigger value="general">
|
||||
<Icon name="sliders" />
|
||||
General
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="shortcuts">
|
||||
<Icon name="keyboard" />
|
||||
Shortcuts
|
||||
</Tabs.Trigger>
|
||||
</div>
|
||||
</div>
|
||||
{/* <Tabs.SectionTitle>Server</Tabs.SectionTitle> */}
|
||||
{/* <Tabs.Trigger value="permissions"> */}
|
||||
{/* <Icon name="checklist" /> */}
|
||||
{/* Permissions */}
|
||||
{/* </Tabs.Trigger> */}
|
||||
{/* <Tabs.Trigger value="providers"> */}
|
||||
{/* <Icon name="server" /> */}
|
||||
{/* Providers */}
|
||||
{/* </Tabs.Trigger> */}
|
||||
{/* <Tabs.Trigger value="models"> */}
|
||||
{/* <Icon name="brain" /> */}
|
||||
{/* Models */}
|
||||
{/* </Tabs.Trigger> */}
|
||||
{/* <Tabs.Trigger value="agents"> */}
|
||||
{/* <Icon name="task" /> */}
|
||||
{/* Agents */}
|
||||
{/* </Tabs.Trigger> */}
|
||||
{/* <Tabs.Trigger value="commands"> */}
|
||||
{/* <Icon name="console" /> */}
|
||||
{/* Commands */}
|
||||
{/* </Tabs.Trigger> */}
|
||||
{/* <Tabs.Trigger value="mcp"> */}
|
||||
{/* <Icon name="mcp" /> */}
|
||||
{/* MCP */}
|
||||
{/* </Tabs.Trigger> */}
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="general" class="no-scrollbar">
|
||||
<SettingsGeneral />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="shortcuts" class="no-scrollbar">
|
||||
<SettingsKeybinds />
|
||||
</Tabs.Content>
|
||||
{/* <Tabs.Content value="permissions" class="no-scrollbar"> */}
|
||||
{/* <SettingsPermissions /> */}
|
||||
{/* </Tabs.Content> */}
|
||||
{/* <Tabs.Content value="providers" class="no-scrollbar"> */}
|
||||
{/* <SettingsProviders /> */}
|
||||
{/* </Tabs.Content> */}
|
||||
{/* <Tabs.Content value="models" class="no-scrollbar"> */}
|
||||
{/* <SettingsModels /> */}
|
||||
{/* </Tabs.Content> */}
|
||||
{/* <Tabs.Content value="agents" class="no-scrollbar"> */}
|
||||
{/* <SettingsAgents /> */}
|
||||
{/* </Tabs.Content> */}
|
||||
{/* <Tabs.Content value="commands" class="no-scrollbar"> */}
|
||||
{/* <SettingsCommands /> */}
|
||||
{/* </Tabs.Content> */}
|
||||
{/* <Tabs.Content value="mcp" class="no-scrollbar"> */}
|
||||
{/* <SettingsMcp /> */}
|
||||
{/* </Tabs.Content> */}
|
||||
</Tabs>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -255,7 +255,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
createEffect(() => {
|
||||
params.id
|
||||
editorRef.focus()
|
||||
if (params.id) return
|
||||
const interval = setInterval(() => {
|
||||
setStore("placeholder", (prev) => (prev + 1) % PLACEHOLDERS.length)
|
||||
|
||||
@@ -45,6 +45,8 @@ export function SessionHeader() {
|
||||
|
||||
const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id))
|
||||
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
|
||||
const showReview = createMemo(() => !!currentSession()?.summary?.files)
|
||||
const showShare = createMemo(() => shareEnabled() && !!currentSession())
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const view = createMemo(() => layout.view(sessionKey()))
|
||||
|
||||
@@ -172,12 +174,14 @@ export function SessionHeader() {
|
||||
{/* <SessionMcpIndicator /> */}
|
||||
{/* </div> */}
|
||||
<div class="flex items-center gap-1">
|
||||
<Show when={currentSession()?.summary?.files}>
|
||||
<TooltipKeybind
|
||||
class="hidden md:block shrink-0"
|
||||
title="Toggle review"
|
||||
keybind={command.keybind("review.toggle")}
|
||||
>
|
||||
<div
|
||||
class="hidden md:block shrink-0"
|
||||
classList={{
|
||||
"opacity-0 pointer-events-none": !showReview(),
|
||||
}}
|
||||
aria-hidden={!showReview()}
|
||||
>
|
||||
<TooltipKeybind title="Toggle review" keybind={command.keybind("review.toggle")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="group/review-toggle size-6 p-0"
|
||||
@@ -202,7 +206,7 @@ export function SessionHeader() {
|
||||
</div>
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</Show>
|
||||
</div>
|
||||
<TooltipKeybind
|
||||
class="hidden md:block shrink-0"
|
||||
title="Toggle terminal"
|
||||
@@ -233,83 +237,87 @@ export function SessionHeader() {
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<Show when={shareEnabled() && currentSession()}>
|
||||
<div class="flex items-center">
|
||||
<Popover
|
||||
title="Publish on web"
|
||||
description={
|
||||
shareUrl()
|
||||
? "This session is public on the web. It is accessible to anyone with the link."
|
||||
: "Share session publicly on the web. It will be accessible to anyone with the link."
|
||||
}
|
||||
trigger={
|
||||
<Tooltip class="shrink-0" value="Share session">
|
||||
<Button
|
||||
variant="secondary"
|
||||
classList={{ "rounded-r-none": shareUrl() !== undefined }}
|
||||
style={{ scale: 1 }}
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Show
|
||||
when={shareUrl()}
|
||||
fallback={
|
||||
<div class="flex">
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-1/2"
|
||||
onClick={shareSession}
|
||||
disabled={state.share}
|
||||
>
|
||||
{state.share ? "Publishing..." : "Publish"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-2 w-72">
|
||||
<TextField value={shareUrl() ?? ""} readOnly copyable class="w-full" />
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
class="w-full shadow-none border border-border-weak-base"
|
||||
onClick={unshareSession}
|
||||
disabled={state.unshare}
|
||||
>
|
||||
{state.unshare ? "Unpublishing..." : "Unpublish"}
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={viewShare}
|
||||
disabled={state.unshare}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Popover>
|
||||
<Show when={shareUrl()} fallback={<div class="size-6" aria-hidden="true" />}>
|
||||
<Tooltip value={state.copied ? "Copied" : "Copy link"} placement="top" gutter={8}>
|
||||
<IconButton
|
||||
icon={state.copied ? "check" : "copy"}
|
||||
<div
|
||||
class="flex items-center"
|
||||
classList={{
|
||||
"opacity-0 pointer-events-none": !showShare(),
|
||||
}}
|
||||
aria-hidden={!showShare()}
|
||||
>
|
||||
<Popover
|
||||
title="Publish on web"
|
||||
description={
|
||||
shareUrl()
|
||||
? "This session is public on the web. It is accessible to anyone with the link."
|
||||
: "Share session publicly on the web. It will be accessible to anyone with the link."
|
||||
}
|
||||
trigger={
|
||||
<Tooltip class="shrink-0" value="Share session">
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="rounded-l-none"
|
||||
onClick={copyLink}
|
||||
disabled={state.unshare}
|
||||
/>
|
||||
classList={{ "rounded-r-none": shareUrl() !== undefined }}
|
||||
style={{ scale: 1 }}
|
||||
>
|
||||
Share
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Show
|
||||
when={shareUrl()}
|
||||
fallback={
|
||||
<div class="flex">
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-1/2"
|
||||
onClick={shareSession}
|
||||
disabled={state.share}
|
||||
>
|
||||
{state.share ? "Publishing..." : "Publish"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-2 w-72">
|
||||
<TextField value={shareUrl() ?? ""} readOnly copyable class="w-full" />
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
class="w-full shadow-none border border-border-weak-base"
|
||||
onClick={unshareSession}
|
||||
disabled={state.unshare}
|
||||
>
|
||||
{state.unshare ? "Unpublishing..." : "Unpublish"}
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
class="w-full"
|
||||
onClick={viewShare}
|
||||
disabled={state.unshare}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Popover>
|
||||
<Show when={shareUrl()} fallback={<div class="size-6" aria-hidden="true" />}>
|
||||
<Tooltip value={state.copied ? "Copied" : "Copy link"} placement="top" gutter={8}>
|
||||
<IconButton
|
||||
icon={state.copied ? "check" : "copy"}
|
||||
variant="secondary"
|
||||
class="rounded-l-none"
|
||||
onClick={copyLink}
|
||||
disabled={state.unshare}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Component } from "solid-js"
|
||||
|
||||
export const SettingsAgents: Component = () => {
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto">
|
||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
||||
<h2 class="text-16-medium text-text-strong">Agents</h2>
|
||||
<p class="text-14-regular text-text-weak">Agent settings will be configurable here.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Component } from "solid-js"
|
||||
|
||||
export const SettingsCommands: Component = () => {
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto">
|
||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
||||
<h2 class="text-16-medium text-text-strong">Commands</h2>
|
||||
<p class="text-14-regular text-text-weak">Command settings will be configurable here.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { Component, createMemo, type JSX } from "solid-js"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||
import { useSettings, monoFontFamily } from "@/context/settings"
|
||||
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
|
||||
|
||||
export const SettingsGeneral: Component = () => {
|
||||
const theme = useTheme()
|
||||
const settings = useSettings()
|
||||
|
||||
const themeOptions = createMemo(() =>
|
||||
Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })),
|
||||
)
|
||||
|
||||
const colorSchemeOptions: { value: ColorScheme; label: string }[] = [
|
||||
{ value: "system", label: "System setting" },
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
]
|
||||
|
||||
const fontOptions = [
|
||||
{ value: "ibm-plex-mono", label: "IBM Plex Mono" },
|
||||
{ value: "cascadia-code", label: "Cascadia Code" },
|
||||
{ value: "fira-code", label: "Fira Code" },
|
||||
{ value: "hack", label: "Hack" },
|
||||
{ value: "inconsolata", label: "Inconsolata" },
|
||||
{ value: "intel-one-mono", label: "Intel One Mono" },
|
||||
{ value: "jetbrains-mono", label: "JetBrains Mono" },
|
||||
{ value: "meslo-lgs", label: "Meslo LGS" },
|
||||
{ value: "roboto-mono", label: "Roboto Mono" },
|
||||
{ value: "source-code-pro", label: "Source Code Pro" },
|
||||
{ value: "ubuntu-mono", label: "Ubuntu Mono" },
|
||||
]
|
||||
|
||||
const soundOptions = [...SOUND_OPTIONS]
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar" style={{ padding: "0 40px 40px 40px" }}>
|
||||
<div
|
||||
class="sticky top-0 z-10"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha) calc(100% - 24px), transparent)",
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col gap-1 pt-6 pb-8">
|
||||
<h2 class="text-16-medium text-text-strong">General</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-8 w-full">
|
||||
{/* Appearance Section */}
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">Appearance</h3>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsRow title="Appearance" description="Customise how OpenCode looks on your device">
|
||||
<Select
|
||||
options={colorSchemeOptions}
|
||||
current={colorSchemeOptions.find((o) => o.value === theme.colorScheme())}
|
||||
value={(o) => o.value}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => option && theme.setColorScheme(option.value)}
|
||||
onHighlight={(option) => {
|
||||
if (!option) return
|
||||
theme.previewColorScheme(option.value)
|
||||
return () => theme.cancelPreview()
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title="Theme"
|
||||
description={
|
||||
<>
|
||||
Customise how OpenCode is themed.{" "}
|
||||
<a href="#" class="text-text-interactive-base">
|
||||
Learn more
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Select
|
||||
options={themeOptions()}
|
||||
current={themeOptions().find((o) => o.id === theme.themeId())}
|
||||
value={(o) => o.id}
|
||||
label={(o) => o.name}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
theme.setTheme(option.id)
|
||||
}}
|
||||
onHighlight={(option) => {
|
||||
if (!option) return
|
||||
theme.previewTheme(option.id)
|
||||
return () => theme.cancelPreview()
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow title="Font" description="Customise the mono font used in code blocks">
|
||||
<Select
|
||||
options={fontOptions}
|
||||
current={fontOptions.find((o) => o.value === settings.appearance.font())}
|
||||
value={(o) => o.value}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => option && settings.appearance.setFont(option.value)}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
|
||||
>
|
||||
{(option) => <span style={{ "font-family": monoFontFamily(option?.value) }}>{option?.label}</span>}
|
||||
</Select>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System notifications Section */}
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">System notifications</h3>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsRow
|
||||
title="Agent"
|
||||
description="Show system notification when the agent is complete or needs attention"
|
||||
>
|
||||
<Switch
|
||||
checked={settings.notifications.agent()}
|
||||
onChange={(checked) => settings.notifications.setAgent(checked)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow title="Permissions" description="Show system notification when a permission is required">
|
||||
<Switch
|
||||
checked={settings.notifications.permissions()}
|
||||
onChange={(checked) => settings.notifications.setPermissions(checked)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow title="Errors" description="Show system notification when an error occurs">
|
||||
<Switch
|
||||
checked={settings.notifications.errors()}
|
||||
onChange={(checked) => settings.notifications.setErrors(checked)}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sound effects Section */}
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">Sound effects</h3>
|
||||
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<SettingsRow title="Agent" description="Play sound when the agent is complete or needs attention">
|
||||
<Select
|
||||
options={soundOptions}
|
||||
current={soundOptions.find((o) => o.id === settings.sounds.agent())}
|
||||
value={(o) => o.id}
|
||||
label={(o) => o.label}
|
||||
onHighlight={(option) => {
|
||||
if (!option) return
|
||||
playSound(option.src)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
settings.sounds.setAgent(option.id)
|
||||
playSound(option.src)
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow title="Permissions" description="Play sound when a permission is required">
|
||||
<Select
|
||||
options={soundOptions}
|
||||
current={soundOptions.find((o) => o.id === settings.sounds.permissions())}
|
||||
value={(o) => o.id}
|
||||
label={(o) => o.label}
|
||||
onHighlight={(option) => {
|
||||
if (!option) return
|
||||
playSound(option.src)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
settings.sounds.setPermissions(option.id)
|
||||
playSound(option.src)
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow title="Errors" description="Play sound when an error occurs">
|
||||
<Select
|
||||
options={soundOptions}
|
||||
current={soundOptions.find((o) => o.id === settings.sounds.errors())}
|
||||
value={(o) => o.id}
|
||||
label={(o) => o.label}
|
||||
onHighlight={(option) => {
|
||||
if (!option) return
|
||||
playSound(option.src)
|
||||
}}
|
||||
onSelect={(option) => {
|
||||
if (!option) return
|
||||
settings.sounds.setErrors(option.id)
|
||||
playSound(option.src)
|
||||
}}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SettingsRowProps {
|
||||
title: string
|
||||
description: string | JSX.Element
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
const SettingsRow: Component<SettingsRowProps> = (props) => {
|
||||
return (
|
||||
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-14-medium text-text-strong">{props.title}</span>
|
||||
<span class="text-12-regular text-text-weak">{props.description}</span>
|
||||
</div>
|
||||
<div class="flex-shrink-0">{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { Component, For, Show, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
|
||||
import { useSettings } from "@/context/settings"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
const PALETTE_ID = "command.palette"
|
||||
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
|
||||
|
||||
type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt"
|
||||
|
||||
type KeybindMeta = {
|
||||
title: string
|
||||
group: KeybindGroup
|
||||
}
|
||||
|
||||
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
|
||||
|
||||
function groupFor(id: string): KeybindGroup {
|
||||
if (id === PALETTE_ID) return "General"
|
||||
if (id.startsWith("terminal.")) return "Terminal"
|
||||
if (id.startsWith("model.") || id.startsWith("agent.") || id.startsWith("mcp.")) return "Model and agent"
|
||||
if (id.startsWith("file.")) return "Navigation"
|
||||
if (id.startsWith("prompt.")) return "Prompt"
|
||||
if (
|
||||
id.startsWith("session.") ||
|
||||
id.startsWith("message.") ||
|
||||
id.startsWith("permissions.") ||
|
||||
id.startsWith("steps.") ||
|
||||
id.startsWith("review.")
|
||||
)
|
||||
return "Session"
|
||||
|
||||
return "General"
|
||||
}
|
||||
|
||||
function isModifier(key: string) {
|
||||
return key === "Shift" || key === "Control" || key === "Alt" || key === "Meta"
|
||||
}
|
||||
|
||||
function normalizeKey(key: string) {
|
||||
if (key === ",") return "comma"
|
||||
if (key === "+") return "plus"
|
||||
if (key === " ") return "space"
|
||||
return key.toLowerCase()
|
||||
}
|
||||
|
||||
function recordKeybind(event: KeyboardEvent) {
|
||||
if (isModifier(event.key)) return
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
const mod = IS_MAC ? event.metaKey : event.ctrlKey
|
||||
if (mod) parts.push("mod")
|
||||
|
||||
if (IS_MAC && event.ctrlKey) parts.push("ctrl")
|
||||
if (!IS_MAC && event.metaKey) parts.push("meta")
|
||||
if (event.altKey) parts.push("alt")
|
||||
if (event.shiftKey) parts.push("shift")
|
||||
|
||||
const key = normalizeKey(event.key)
|
||||
if (!key) return
|
||||
parts.push(key)
|
||||
|
||||
return parts.join("+")
|
||||
}
|
||||
|
||||
function signatures(config: string | undefined) {
|
||||
if (!config) return []
|
||||
const sigs: string[] = []
|
||||
|
||||
for (const kb of parseKeybind(config)) {
|
||||
const parts: string[] = []
|
||||
if (kb.ctrl) parts.push("ctrl")
|
||||
if (kb.alt) parts.push("alt")
|
||||
if (kb.shift) parts.push("shift")
|
||||
if (kb.meta) parts.push("meta")
|
||||
if (kb.key) parts.push(kb.key)
|
||||
if (parts.length === 0) continue
|
||||
sigs.push(parts.join("+"))
|
||||
}
|
||||
|
||||
return sigs
|
||||
}
|
||||
|
||||
export const SettingsKeybinds: Component = () => {
|
||||
const command = useCommand()
|
||||
const settings = useSettings()
|
||||
|
||||
const [active, setActive] = createSignal<string | null>(null)
|
||||
|
||||
const stop = () => {
|
||||
if (!active()) return
|
||||
setActive(null)
|
||||
command.keybinds(true)
|
||||
}
|
||||
|
||||
const start = (id: string) => {
|
||||
if (active() === id) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
if (active()) stop()
|
||||
|
||||
setActive(id)
|
||||
command.keybinds(false)
|
||||
}
|
||||
|
||||
const hasOverrides = createMemo(() => {
|
||||
const keybinds = settings.current.keybinds as Record<string, string | undefined> | undefined
|
||||
if (!keybinds) return false
|
||||
return Object.values(keybinds).some((x) => typeof x === "string")
|
||||
})
|
||||
|
||||
const resetAll = () => {
|
||||
stop()
|
||||
settings.keybinds.resetAll()
|
||||
showToast({ title: "Shortcuts reset", description: "Keyboard shortcuts have been reset to defaults." })
|
||||
}
|
||||
|
||||
const list = createMemo(() => {
|
||||
const out = new Map<string, KeybindMeta>()
|
||||
out.set(PALETTE_ID, { title: "Command palette", group: "General" })
|
||||
|
||||
for (const opt of command.catalog) {
|
||||
if (opt.id.startsWith("suggested.")) continue
|
||||
out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
|
||||
}
|
||||
|
||||
for (const opt of command.options) {
|
||||
if (opt.id.startsWith("suggested.")) continue
|
||||
out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
|
||||
}
|
||||
|
||||
const keybinds = settings.current.keybinds as Record<string, string | undefined> | undefined
|
||||
if (keybinds) {
|
||||
for (const [id, value] of Object.entries(keybinds)) {
|
||||
if (typeof value !== "string") continue
|
||||
if (out.has(id)) continue
|
||||
out.set(id, { title: id, group: groupFor(id) })
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
})
|
||||
|
||||
const title = (id: string) => list().get(id)?.title ?? ""
|
||||
|
||||
const grouped = createMemo(() => {
|
||||
const map = list()
|
||||
const out = new Map<KeybindGroup, string[]>()
|
||||
|
||||
for (const group of GROUPS) out.set(group, [])
|
||||
|
||||
for (const [id, item] of map) {
|
||||
const ids = out.get(item.group)
|
||||
if (!ids) continue
|
||||
ids.push(id)
|
||||
}
|
||||
|
||||
for (const group of GROUPS) {
|
||||
const ids = out.get(group)
|
||||
if (!ids) continue
|
||||
|
||||
ids.sort((a, b) => {
|
||||
const at = map.get(a)?.title ?? ""
|
||||
const bt = map.get(b)?.title ?? ""
|
||||
return at.localeCompare(bt)
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
})
|
||||
|
||||
const used = createMemo(() => {
|
||||
const map = new Map<string, { id: string; title: string }[]>()
|
||||
|
||||
const add = (key: string, value: { id: string; title: string }) => {
|
||||
const list = map.get(key)
|
||||
if (!list) {
|
||||
map.set(key, [value])
|
||||
return
|
||||
}
|
||||
list.push(value)
|
||||
}
|
||||
|
||||
const palette = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
|
||||
for (const sig of signatures(palette)) {
|
||||
add(sig, { id: PALETTE_ID, title: "Command palette" })
|
||||
}
|
||||
|
||||
const valueFor = (id: string) => {
|
||||
const custom = settings.keybinds.get(id)
|
||||
if (typeof custom === "string") return custom
|
||||
|
||||
const live = command.options.find((x) => x.id === id)
|
||||
if (live?.keybind) return live.keybind
|
||||
|
||||
const meta = command.catalog.find((x) => x.id === id)
|
||||
return meta?.keybind
|
||||
}
|
||||
|
||||
for (const id of list().keys()) {
|
||||
if (id === PALETTE_ID) continue
|
||||
for (const sig of signatures(valueFor(id))) {
|
||||
add(sig, { id, title: title(id) })
|
||||
}
|
||||
}
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
const setKeybind = (id: string, keybind: string) => {
|
||||
settings.keybinds.set(id, keybind)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const handle = (event: KeyboardEvent) => {
|
||||
const id = active()
|
||||
if (!id) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.stopImmediatePropagation()
|
||||
|
||||
if (event.key === "Escape") {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
const clear =
|
||||
(event.key === "Backspace" || event.key === "Delete") &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey
|
||||
if (clear) {
|
||||
setKeybind(id, "none")
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
const next = recordKeybind(event)
|
||||
if (!next) return
|
||||
|
||||
const map = used()
|
||||
const conflicts = new Map<string, string>()
|
||||
|
||||
for (const sig of signatures(next)) {
|
||||
const list = map.get(sig) ?? []
|
||||
for (const item of list) {
|
||||
if (item.id === id) continue
|
||||
conflicts.set(item.id, item.title)
|
||||
}
|
||||
}
|
||||
|
||||
if (conflicts.size > 0) {
|
||||
showToast({
|
||||
title: "Shortcut already in use",
|
||||
description: `${formatKeybind(next)} is already assigned to ${[...conflicts.values()].join(", ")}.`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setKeybind(id, next)
|
||||
stop()
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handle, true)
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handle, true)
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (active()) command.keybinds(true)
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar" style={{ padding: "0 40px 40px 40px" }}>
|
||||
<div
|
||||
class="sticky top-0 z-10"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha) calc(100% - 24px), transparent)",
|
||||
}}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4 pt-6 pb-8 max-w-[720px]">
|
||||
<h2 class="text-16-medium text-text-strong">Keyboard shortcuts</h2>
|
||||
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
|
||||
Reset to defaults
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-8 max-w-[720px]">
|
||||
<For each={GROUPS}>
|
||||
{(group) => (
|
||||
<Show when={(grouped().get(group) ?? []).length > 0}>
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="text-14-medium text-text-strong pb-2">{group}</h3>
|
||||
<div class="bg-surface-raised-base px-4 rounded-lg">
|
||||
<For each={grouped().get(group) ?? []}>
|
||||
{(id) => (
|
||||
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
|
||||
<span class="text-14-regular text-text-strong">{title(id)}</span>
|
||||
<button
|
||||
type="button"
|
||||
classList={{
|
||||
"h-8 px-3 rounded-md text-12-regular": true,
|
||||
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
|
||||
active() !== id,
|
||||
"border border-border-weak-base bg-surface-inset-base text-text-weak": active() === id,
|
||||
}}
|
||||
onClick={() => start(id)}
|
||||
>
|
||||
<Show when={active() === id} fallback={command.keybind(id) || "Unassigned"}>
|
||||
Press keys
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Component } from "solid-js"
|
||||
|
||||
export const SettingsMcp: Component = () => {
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto">
|
||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
||||
<h2 class="text-16-medium text-text-strong">MCP</h2>
|
||||
<p class="text-14-regular text-text-weak">MCP settings will be configurable here.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Component } from "solid-js"
|
||||
|
||||
export const SettingsModels: Component = () => {
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto">
|
||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
||||
<h2 class="text-16-medium text-text-strong">Models</h2>
|
||||
<p class="text-14-regular text-text-weak">Model settings will be configurable here.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { Component, For, createMemo, type JSX } from "solid-js"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
|
||||
type PermissionAction = "allow" | "ask" | "deny"
|
||||
|
||||
type PermissionObject = Record<string, PermissionAction>
|
||||
type PermissionValue = PermissionAction | PermissionObject | string[] | undefined
|
||||
type PermissionMap = Record<string, PermissionValue>
|
||||
|
||||
type PermissionItem = {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const ACTIONS: Array<{ value: PermissionAction; label: string }> = [
|
||||
{ value: "allow", label: "Allow" },
|
||||
{ value: "ask", label: "Ask" },
|
||||
{ value: "deny", label: "Deny" },
|
||||
]
|
||||
|
||||
const ITEMS: PermissionItem[] = [
|
||||
{ id: "read", title: "Read", description: "Reading a file (matches the file path)" },
|
||||
{ id: "edit", title: "Edit", description: "Modify files, including edits, writes, patches, and multi-edits" },
|
||||
{ id: "glob", title: "Glob", description: "Match files using glob patterns" },
|
||||
{ id: "grep", title: "Grep", description: "Search file contents using regular expressions" },
|
||||
{ id: "list", title: "List", description: "List files within a directory" },
|
||||
{ id: "bash", title: "Bash", description: "Run shell commands" },
|
||||
{ id: "task", title: "Task", description: "Launch sub-agents" },
|
||||
{ id: "skill", title: "Skill", description: "Load a skill by name" },
|
||||
{ id: "lsp", title: "LSP", description: "Run language server queries" },
|
||||
{ id: "todoread", title: "Todo Read", description: "Read the todo list" },
|
||||
{ id: "todowrite", title: "Todo Write", description: "Update the todo list" },
|
||||
{ id: "webfetch", title: "Web Fetch", description: "Fetch content from a URL" },
|
||||
{ id: "websearch", title: "Web Search", description: "Search the web" },
|
||||
{ id: "codesearch", title: "Code Search", description: "Search code on the web" },
|
||||
{ id: "external_directory", title: "External Directory", description: "Access files outside the project directory" },
|
||||
{ id: "doom_loop", title: "Doom Loop", description: "Detect repeated tool calls with identical input" },
|
||||
]
|
||||
|
||||
const VALID_ACTIONS = new Set<PermissionAction>(["allow", "ask", "deny"])
|
||||
|
||||
function toMap(value: unknown): PermissionMap {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) return value as PermissionMap
|
||||
|
||||
const action = getAction(value)
|
||||
if (action) return { "*": action }
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
function getAction(value: unknown): PermissionAction | undefined {
|
||||
if (typeof value === "string" && VALID_ACTIONS.has(value as PermissionAction)) return value as PermissionAction
|
||||
return
|
||||
}
|
||||
|
||||
function getRuleDefault(value: unknown): PermissionAction | undefined {
|
||||
const action = getAction(value)
|
||||
if (action) return action
|
||||
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return
|
||||
|
||||
return getAction((value as Record<string, unknown>)["*"])
|
||||
}
|
||||
|
||||
export const SettingsPermissions: Component = () => {
|
||||
const globalSync = useGlobalSync()
|
||||
|
||||
const permission = createMemo(() => {
|
||||
return toMap(globalSync.data.config.permission)
|
||||
})
|
||||
|
||||
const actionFor = (id: string): PermissionAction => {
|
||||
const value = permission()[id]
|
||||
const direct = getRuleDefault(value)
|
||||
if (direct) return direct
|
||||
|
||||
const wildcard = getRuleDefault(permission()["*"])
|
||||
if (wildcard) return wildcard
|
||||
|
||||
return "allow"
|
||||
}
|
||||
|
||||
const setPermission = async (id: string, action: PermissionAction) => {
|
||||
const before = globalSync.data.config.permission
|
||||
const map = toMap(before)
|
||||
const existing = map[id]
|
||||
|
||||
const nextValue =
|
||||
existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action
|
||||
|
||||
globalSync.set("config", "permission", { ...map, [id]: nextValue })
|
||||
globalSync.updateConfig({ permission: { [id]: nextValue } }).catch((err: unknown) => {
|
||||
globalSync.set("config", "permission", before)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: "Failed to update permissions", description: message })
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto no-scrollbar">
|
||||
<div
|
||||
class="sticky top-0 z-10"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha) calc(100% - 24px), transparent)",
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col gap-1 p-8 max-w-[720px]">
|
||||
<h2 class="text-16-medium text-text-strong">Permissions</h2>
|
||||
<p class="text-14-regular text-text-weak">Control what tools the server can use by default.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6 p-8 pt-6 max-w-[720px]">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-14-medium text-text-strong">Appearance</h3>
|
||||
<div class="border border-border-weak-base rounded-lg overflow-hidden">
|
||||
<For each={ITEMS}>
|
||||
{(item) => (
|
||||
<SettingsRow title={item.title} description={item.description}>
|
||||
<Select
|
||||
options={ACTIONS}
|
||||
current={ACTIONS.find((o) => o.value === actionFor(item.id))}
|
||||
value={(o) => o.value}
|
||||
label={(o) => o.label}
|
||||
onSelect={(option) => option && setPermission(item.id, option.value)}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
/>
|
||||
</SettingsRow>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SettingsRowProps {
|
||||
title: string
|
||||
description: string
|
||||
children: JSX.Element
|
||||
}
|
||||
|
||||
const SettingsRow: Component<SettingsRowProps> = (props) => {
|
||||
return (
|
||||
<div class="flex items-center justify-between gap-4 px-4 py-3 border-b border-border-weak-base last:border-none">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<span class="text-14-medium text-text-strong">{props.title}</span>
|
||||
<span class="text-12-regular text-text-weak">{props.description}</span>
|
||||
</div>
|
||||
<div class="flex-shrink-0">{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Component } from "solid-js"
|
||||
|
||||
export const SettingsProviders: Component = () => {
|
||||
return (
|
||||
<div class="flex flex-col h-full overflow-y-auto">
|
||||
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
|
||||
<h2 class="text-16-medium text-text-strong">Providers</h2>
|
||||
<p class="text-14-regular text-text-weak">Provider settings will be configurable here.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
|
||||
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { monoFontFamily, useSettings } from "@/context/settings"
|
||||
import { SerializeAddon } from "@/addons/serialize"
|
||||
import { LocalPTY } from "@/context/terminal"
|
||||
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
|
||||
@@ -36,6 +37,7 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
|
||||
|
||||
export const Terminal = (props: TerminalProps) => {
|
||||
const sdk = useSDK()
|
||||
const settings = useSettings()
|
||||
const theme = useTheme()
|
||||
let container!: HTMLDivElement
|
||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
|
||||
@@ -82,6 +84,14 @@ export const Terminal = (props: TerminalProps) => {
|
||||
setOption("theme", colors)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const font = monoFontFamily(settings.appearance.font())
|
||||
if (!term) return
|
||||
const setOption = (term as unknown as { setOption?: (key: string, value: string) => void }).setOption
|
||||
if (!setOption) return
|
||||
setOption("fontFamily", font)
|
||||
})
|
||||
|
||||
const focusTerminal = () => {
|
||||
const t = term
|
||||
if (!t) return
|
||||
@@ -112,7 +122,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
cursorBlink: true,
|
||||
cursorStyle: "bar",
|
||||
fontSize: 14,
|
||||
fontFamily: "IBM Plex Mono, monospace",
|
||||
fontFamily: monoFontFamily(settings.appearance.font()),
|
||||
allowTransparency: true,
|
||||
theme: terminalColors(),
|
||||
scrollback: 10_000,
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
import { createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
|
||||
const PALETTE_ID = "command.palette"
|
||||
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
|
||||
const SUGGESTED_PREFIX = "suggested."
|
||||
|
||||
function actionId(id: string) {
|
||||
if (!id.startsWith(SUGGESTED_PREFIX)) return id
|
||||
return id.slice(SUGGESTED_PREFIX.length)
|
||||
}
|
||||
|
||||
function normalizeKey(key: string) {
|
||||
if (key === ",") return "comma"
|
||||
if (key === "+") return "plus"
|
||||
if (key === " ") return "space"
|
||||
return key.toLowerCase()
|
||||
}
|
||||
|
||||
export type KeybindConfig = string
|
||||
|
||||
export interface Keybind {
|
||||
@@ -27,6 +46,14 @@ export interface CommandOption {
|
||||
onHighlight?: () => (() => void) | void
|
||||
}
|
||||
|
||||
export type CommandCatalogItem = {
|
||||
title: string
|
||||
description?: string
|
||||
category?: string
|
||||
keybind?: KeybindConfig
|
||||
slash?: string
|
||||
}
|
||||
|
||||
export function parseKeybind(config: string): Keybind[] {
|
||||
if (!config || config === "none") return []
|
||||
|
||||
@@ -73,7 +100,7 @@ export function parseKeybind(config: string): Keybind[] {
|
||||
}
|
||||
|
||||
export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
|
||||
const eventKey = event.key.toLowerCase()
|
||||
const eventKey = normalizeKey(event.key)
|
||||
|
||||
for (const kb of keybinds) {
|
||||
const keyMatch = kb.key === eventKey
|
||||
@@ -105,15 +132,17 @@ export function formatKeybind(config: string): string {
|
||||
if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
|
||||
|
||||
if (kb.key) {
|
||||
const arrows: Record<string, string> = {
|
||||
const keys: Record<string, string> = {
|
||||
arrowup: "↑",
|
||||
arrowdown: "↓",
|
||||
arrowleft: "←",
|
||||
arrowright: "→",
|
||||
comma: ",",
|
||||
plus: "+",
|
||||
space: "Space",
|
||||
}
|
||||
const displayKey =
|
||||
arrows[kb.key.toLowerCase()] ??
|
||||
(kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1))
|
||||
const key = kb.key.toLowerCase()
|
||||
const displayKey = keys[key] ?? (key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1))
|
||||
parts.push(displayKey)
|
||||
}
|
||||
|
||||
@@ -124,10 +153,23 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
name: "Command",
|
||||
init: () => {
|
||||
const dialog = useDialog()
|
||||
const settings = useSettings()
|
||||
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
|
||||
const [suspendCount, setSuspendCount] = createSignal(0)
|
||||
|
||||
const options = createMemo(() => {
|
||||
const [catalog, setCatalog, _, catalogReady] = persisted(
|
||||
Persist.global("command.catalog.v1"),
|
||||
createStore<Record<string, CommandCatalogItem>>({}),
|
||||
)
|
||||
|
||||
const bind = (id: string, def: KeybindConfig | undefined) => {
|
||||
const custom = settings.keybinds.get(actionId(id))
|
||||
const config = custom ?? def
|
||||
if (!config || config === "none") return
|
||||
return config
|
||||
}
|
||||
|
||||
const registered = createMemo(() => {
|
||||
const seen = new Set<string>()
|
||||
const all: CommandOption[] = []
|
||||
|
||||
@@ -139,15 +181,41 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
}
|
||||
}
|
||||
|
||||
const suggested = all.filter((x) => x.suggested && !x.disabled)
|
||||
return all
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!catalogReady()) return
|
||||
|
||||
for (const opt of registered()) {
|
||||
const id = actionId(opt.id)
|
||||
setCatalog(id, {
|
||||
title: opt.title,
|
||||
description: opt.description,
|
||||
category: opt.category,
|
||||
keybind: opt.keybind,
|
||||
slash: opt.slash,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta })))
|
||||
|
||||
const options = createMemo(() => {
|
||||
const resolved = registered().map((opt) => ({
|
||||
...opt,
|
||||
keybind: bind(opt.id, opt.keybind),
|
||||
}))
|
||||
|
||||
const suggested = resolved.filter((x) => x.suggested && !x.disabled)
|
||||
|
||||
return [
|
||||
...suggested.map((x) => ({
|
||||
...x,
|
||||
id: "suggested." + x.id,
|
||||
id: SUGGESTED_PREFIX + x.id,
|
||||
category: "Suggested",
|
||||
})),
|
||||
...all,
|
||||
...resolved,
|
||||
]
|
||||
})
|
||||
|
||||
@@ -169,7 +237,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (suspended() || dialog.active) return
|
||||
|
||||
const paletteKeybinds = parseKeybind("mod+shift+p")
|
||||
const paletteKeybinds = parseKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND)
|
||||
if (matchKeybind(paletteKeybinds, event)) {
|
||||
event.preventDefault()
|
||||
showPalette()
|
||||
@@ -209,15 +277,27 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
run(id, source)
|
||||
},
|
||||
keybind(id: string) {
|
||||
const option = options().find((x) => x.id === id || x.id === "suggested." + id)
|
||||
if (!option?.keybind) return ""
|
||||
return formatKeybind(option.keybind)
|
||||
if (id === PALETTE_ID) {
|
||||
return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND)
|
||||
}
|
||||
|
||||
const base = actionId(id)
|
||||
const option = options().find((x) => actionId(x.id) === base)
|
||||
if (option?.keybind) return formatKeybind(option.keybind)
|
||||
|
||||
const meta = catalog[base]
|
||||
const config = bind(base, meta?.keybind)
|
||||
if (!config) return ""
|
||||
return formatKeybind(config)
|
||||
},
|
||||
show: showPalette,
|
||||
keybinds(enabled: boolean) {
|
||||
setSuspendCount((count) => count + (enabled ? -1 : 1))
|
||||
},
|
||||
suspended,
|
||||
get catalog() {
|
||||
return catalogOptions()
|
||||
},
|
||||
get options() {
|
||||
return options()
|
||||
},
|
||||
|
||||
@@ -88,6 +88,10 @@ type VcsCache = {
|
||||
ready: Accessor<boolean>
|
||||
}
|
||||
|
||||
type ChildOptions = {
|
||||
bootstrap?: boolean
|
||||
}
|
||||
|
||||
function createGlobalSync() {
|
||||
const globalSDK = useGlobalSDK()
|
||||
const platform = usePlatform()
|
||||
@@ -101,17 +105,37 @@ function createGlobalSync() {
|
||||
project: Project[]
|
||||
provider: ProviderListResponse
|
||||
provider_auth: ProviderAuthResponse
|
||||
config: Config
|
||||
reload: undefined | "pending" | "complete"
|
||||
}>({
|
||||
ready: false,
|
||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||
project: [],
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
provider_auth: {},
|
||||
config: {},
|
||||
reload: undefined,
|
||||
})
|
||||
let bootstrapQueue: string[] = []
|
||||
|
||||
createEffect(async () => {
|
||||
if (globalStore.reload !== "complete") return
|
||||
if (bootstrapQueue.length) {
|
||||
for (const directory of bootstrapQueue) {
|
||||
bootstrapInstance(directory)
|
||||
}
|
||||
bootstrap()
|
||||
}
|
||||
bootstrapQueue = []
|
||||
setGlobalStore("reload", undefined)
|
||||
})
|
||||
|
||||
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
||||
const booting = new Map<string, Promise<void>>()
|
||||
const sessionLoads = new Map<string, Promise<void>>()
|
||||
const sessionMeta = new Map<string, { limit: number }>()
|
||||
|
||||
function child(directory: string) {
|
||||
function ensureChild(directory: string) {
|
||||
if (!directory) console.error("No directory provided")
|
||||
if (!children[directory]) {
|
||||
const cache = runWithOwner(owner, () =>
|
||||
@@ -146,7 +170,6 @@ function createGlobalSync() {
|
||||
message: {},
|
||||
part: {},
|
||||
})
|
||||
bootstrapInstance(directory)
|
||||
}
|
||||
|
||||
runWithOwner(owner, init)
|
||||
@@ -156,11 +179,24 @@ function createGlobalSync() {
|
||||
return childStore
|
||||
}
|
||||
|
||||
async function loadSessions(directory: string) {
|
||||
const [store, setStore] = child(directory)
|
||||
const limit = store.limit
|
||||
function child(directory: string, options: ChildOptions = {}) {
|
||||
const childStore = ensureChild(directory)
|
||||
const shouldBootstrap = options.bootstrap ?? true
|
||||
if (shouldBootstrap && childStore[0].status === "loading") {
|
||||
void bootstrapInstance(directory)
|
||||
}
|
||||
return childStore
|
||||
}
|
||||
|
||||
return globalSDK.client.session
|
||||
async function loadSessions(directory: string) {
|
||||
const pending = sessionLoads.get(directory)
|
||||
if (pending) return pending
|
||||
|
||||
const [store, setStore] = child(directory, { bootstrap: false })
|
||||
const meta = sessionMeta.get(directory)
|
||||
if (meta && meta.limit >= store.limit) return
|
||||
|
||||
const promise = globalSDK.client.session
|
||||
.list({ directory, roots: true })
|
||||
.then((x) => {
|
||||
const nonArchived = (x.data ?? [])
|
||||
@@ -169,9 +205,15 @@ function createGlobalSync() {
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id))
|
||||
|
||||
// Read the current limit at resolve-time so callers that bump the limit while
|
||||
// a request is in-flight still get the expanded result.
|
||||
const limit = store.limit
|
||||
|
||||
const sandboxWorkspace = globalStore.project.some((p) => (p.sandboxes ?? []).includes(directory))
|
||||
if (sandboxWorkspace) {
|
||||
setStore("sessionTotal", nonArchived.length)
|
||||
setStore("session", reconcile(nonArchived, { key: "id" }))
|
||||
sessionMeta.set(directory, { limit })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -185,136 +227,164 @@ function createGlobalSync() {
|
||||
// Store total session count (used for "load more" pagination)
|
||||
setStore("sessionTotal", nonArchived.length)
|
||||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
sessionMeta.set(directory, { limit })
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to load sessions", err)
|
||||
const project = getFilename(directory)
|
||||
showToast({ title: `Failed to load sessions for ${project}`, description: err.message })
|
||||
})
|
||||
|
||||
sessionLoads.set(directory, promise)
|
||||
promise.finally(() => {
|
||||
sessionLoads.delete(directory)
|
||||
})
|
||||
return promise
|
||||
}
|
||||
|
||||
async function bootstrapInstance(directory: string) {
|
||||
if (!directory) return
|
||||
const [store, setStore] = child(directory)
|
||||
const cache = vcsCache.get(directory)
|
||||
if (!cache) return
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: globalSDK.url,
|
||||
fetch: platform.fetch,
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
const pending = booting.get(directory)
|
||||
if (pending) return pending
|
||||
|
||||
createEffect(() => {
|
||||
if (!cache.ready()) return
|
||||
const cached = cache.store.value
|
||||
if (!cached?.branch) return
|
||||
setStore("vcs", (value) => value ?? cached)
|
||||
})
|
||||
const promise = (async () => {
|
||||
const [store, setStore] = ensureChild(directory)
|
||||
const cache = vcsCache.get(directory)
|
||||
if (!cache) return
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: globalSDK.url,
|
||||
fetch: platform.fetch,
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
const blockingRequests = {
|
||||
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
|
||||
provider: () =>
|
||||
sdk.provider.list().then((x) => {
|
||||
const data = x.data!
|
||||
setStore("provider", {
|
||||
...data,
|
||||
all: data.all.map((provider) => ({
|
||||
...provider,
|
||||
models: Object.fromEntries(
|
||||
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
|
||||
),
|
||||
})),
|
||||
setStore("status", "loading")
|
||||
|
||||
createEffect(() => {
|
||||
if (!cache.ready()) return
|
||||
const cached = cache.store.value
|
||||
if (!cached?.branch) return
|
||||
setStore("vcs", (value) => value ?? cached)
|
||||
})
|
||||
|
||||
const blockingRequests = {
|
||||
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
|
||||
provider: () =>
|
||||
sdk.provider.list().then((x) => {
|
||||
const data = x.data!
|
||||
setStore("provider", {
|
||||
...data,
|
||||
all: data.all.map((provider) => ({
|
||||
...provider,
|
||||
models: Object.fromEntries(
|
||||
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
|
||||
),
|
||||
})),
|
||||
})
|
||||
}),
|
||||
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
|
||||
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
|
||||
} catch (err) {
|
||||
console.error("Failed to bootstrap instance", err)
|
||||
const project = getFilename(directory)
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
showToast({ title: `Failed to reload ${project}`, description: message })
|
||||
setStore("status", "partial")
|
||||
return
|
||||
}
|
||||
|
||||
if (store.status !== "complete") setStore("status", "partial")
|
||||
|
||||
Promise.all([
|
||||
sdk.path.get().then((x) => setStore("path", x.data!)),
|
||||
sdk.command.list().then((x) => setStore("command", x.data ?? [])),
|
||||
sdk.session.status().then((x) => setStore("session_status", x.data!)),
|
||||
loadSessions(directory),
|
||||
sdk.mcp.status().then((x) => setStore("mcp", x.data!)),
|
||||
sdk.lsp.status().then((x) => setStore("lsp", x.data!)),
|
||||
sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? store.vcs
|
||||
setStore("vcs", next)
|
||||
if (next?.branch) cache.setStore("value", next)
|
||||
}),
|
||||
sdk.permission.list().then((x) => {
|
||||
const grouped: Record<string, PermissionRequest[]> = {}
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id || !perm.sessionID) continue
|
||||
const existing = grouped[perm.sessionID]
|
||||
if (existing) {
|
||||
existing.push(perm)
|
||||
continue
|
||||
}
|
||||
grouped[perm.sessionID] = [perm]
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(store.permission)) {
|
||||
if (grouped[sessionID]) continue
|
||||
setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
setStore(
|
||||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions
|
||||
.filter((p) => !!p?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
|
||||
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
|
||||
}
|
||||
await Promise.all(Object.values(blockingRequests).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
|
||||
.then(() => {
|
||||
if (store.status !== "complete") setStore("status", "partial")
|
||||
// non-blocking
|
||||
Promise.all([
|
||||
sdk.path.get().then((x) => setStore("path", x.data!)),
|
||||
sdk.command.list().then((x) => setStore("command", x.data ?? [])),
|
||||
sdk.session.status().then((x) => setStore("session_status", x.data!)),
|
||||
loadSessions(directory),
|
||||
sdk.mcp.status().then((x) => setStore("mcp", x.data!)),
|
||||
sdk.lsp.status().then((x) => setStore("lsp", x.data!)),
|
||||
sdk.vcs.get().then((x) => {
|
||||
const next = x.data ?? store.vcs
|
||||
setStore("vcs", next)
|
||||
if (next?.branch) cache.setStore("value", next)
|
||||
}),
|
||||
sdk.permission.list().then((x) => {
|
||||
const grouped: Record<string, PermissionRequest[]> = {}
|
||||
for (const perm of x.data ?? []) {
|
||||
if (!perm?.id || !perm.sessionID) continue
|
||||
const existing = grouped[perm.sessionID]
|
||||
if (existing) {
|
||||
existing.push(perm)
|
||||
continue
|
||||
}
|
||||
grouped[perm.sessionID] = [perm]
|
||||
sdk.question.list().then((x) => {
|
||||
const grouped: Record<string, QuestionRequest[]> = {}
|
||||
for (const question of x.data ?? []) {
|
||||
if (!question?.id || !question.sessionID) continue
|
||||
const existing = grouped[question.sessionID]
|
||||
if (existing) {
|
||||
existing.push(question)
|
||||
continue
|
||||
}
|
||||
grouped[question.sessionID] = [question]
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(store.permission)) {
|
||||
if (grouped[sessionID]) continue
|
||||
setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
setStore(
|
||||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions
|
||||
.filter((p) => !!p?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
sdk.question.list().then((x) => {
|
||||
const grouped: Record<string, QuestionRequest[]> = {}
|
||||
for (const question of x.data ?? []) {
|
||||
if (!question?.id || !question.sessionID) continue
|
||||
const existing = grouped[question.sessionID]
|
||||
if (existing) {
|
||||
existing.push(question)
|
||||
continue
|
||||
}
|
||||
grouped[question.sessionID] = [question]
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(store.question)) {
|
||||
if (grouped[sessionID]) continue
|
||||
setStore("question", sessionID, [])
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(store.question)) {
|
||||
if (grouped[sessionID]) continue
|
||||
setStore("question", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
setStore(
|
||||
"question",
|
||||
sessionID,
|
||||
reconcile(
|
||||
questions
|
||||
.filter((q) => !!q?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
]).then(() => {
|
||||
setStore("status", "complete")
|
||||
})
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
setStore(
|
||||
"question",
|
||||
sessionID,
|
||||
reconcile(
|
||||
questions
|
||||
.filter((q) => !!q?.id)
|
||||
.slice()
|
||||
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}),
|
||||
]).then(() => {
|
||||
setStore("status", "complete")
|
||||
})
|
||||
.catch((e) => setGlobalStore("error", e))
|
||||
})()
|
||||
|
||||
booting.set(directory, promise)
|
||||
promise.finally(() => {
|
||||
booting.delete(directory)
|
||||
})
|
||||
return promise
|
||||
}
|
||||
|
||||
const unsub = globalSDK.event.listen((e) => {
|
||||
@@ -324,6 +394,7 @@ function createGlobalSync() {
|
||||
if (directory === "global") {
|
||||
switch (event?.type) {
|
||||
case "global.disposed": {
|
||||
if (globalStore.reload) return
|
||||
bootstrap()
|
||||
break
|
||||
}
|
||||
@@ -345,9 +416,16 @@ function createGlobalSync() {
|
||||
return
|
||||
}
|
||||
|
||||
const [store, setStore] = child(directory)
|
||||
const existing = children[directory]
|
||||
if (!existing) return
|
||||
|
||||
const [store, setStore] = existing
|
||||
switch (event.type) {
|
||||
case "server.instance.disposed": {
|
||||
if (globalStore.reload) {
|
||||
bootstrapQueue.push(directory)
|
||||
return
|
||||
}
|
||||
bootstrapInstance(directory)
|
||||
break
|
||||
}
|
||||
@@ -591,6 +669,11 @@ function createGlobalSync() {
|
||||
setGlobalStore("path", x.data!)
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
globalSDK.client.config.get().then((x) => {
|
||||
setGlobalStore("config", x.data!)
|
||||
}),
|
||||
),
|
||||
retry(() =>
|
||||
globalSDK.client.project.list().then(async (x) => {
|
||||
const projects = (x.data ?? [])
|
||||
@@ -631,6 +714,7 @@ function createGlobalSync() {
|
||||
|
||||
return {
|
||||
data: globalStore,
|
||||
set: setGlobalStore,
|
||||
get ready() {
|
||||
return globalStore.ready
|
||||
},
|
||||
@@ -639,6 +723,14 @@ function createGlobalSync() {
|
||||
},
|
||||
child,
|
||||
bootstrap,
|
||||
updateConfig: async (config: Config) => {
|
||||
setGlobalStore("reload", "pending")
|
||||
const response = await globalSDK.client.config.update({ config })
|
||||
setTimeout(() => {
|
||||
setGlobalStore("reload", "complete")
|
||||
}, 1000)
|
||||
return response
|
||||
},
|
||||
project: {
|
||||
loadSessions,
|
||||
},
|
||||
|
||||
@@ -4,13 +4,12 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { EventSessionError } from "@opencode-ai/sdk/v2"
|
||||
import { makeAudioPlayer } from "@solid-primitives/audio"
|
||||
import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
|
||||
import errorSound from "@opencode-ai/ui/audio/nope-03.aac"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { playSound, soundSrc } from "@/utils/sound"
|
||||
|
||||
type NotificationBase = {
|
||||
directory?: string
|
||||
@@ -44,19 +43,10 @@ function pruneNotifications(list: Notification[]) {
|
||||
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
||||
name: "Notification",
|
||||
init: () => {
|
||||
let idlePlayer: ReturnType<typeof makeAudioPlayer> | undefined
|
||||
let errorPlayer: ReturnType<typeof makeAudioPlayer> | undefined
|
||||
|
||||
try {
|
||||
idlePlayer = makeAudioPlayer(idleSound)
|
||||
errorPlayer = makeAudioPlayer(errorSound)
|
||||
} catch (err) {
|
||||
console.log("Failed to load audio", err)
|
||||
}
|
||||
|
||||
const globalSDK = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("notification", ["notification.v1"]),
|
||||
@@ -93,16 +83,20 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
|
||||
const session = match.found ? syncStore.session[match.index] : undefined
|
||||
if (session?.parentID) break
|
||||
try {
|
||||
idlePlayer?.play()
|
||||
} catch {}
|
||||
|
||||
playSound(soundSrc(settings.sounds.agent()))
|
||||
|
||||
append({
|
||||
...base,
|
||||
type: "turn-complete",
|
||||
session: sessionID,
|
||||
})
|
||||
|
||||
const href = `/${base64Encode(directory)}/session/${sessionID}`
|
||||
void platform.notify("Response ready", session?.title ?? sessionID, href)
|
||||
if (settings.notifications.agent()) {
|
||||
void platform.notify("Response ready", session?.title ?? sessionID, href)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
case "session.error": {
|
||||
@@ -111,9 +105,9 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
const match = sessionID ? Binary.search(syncStore.session, sessionID, (s) => s.id) : undefined
|
||||
const session = sessionID && match?.found ? syncStore.session[match.index] : undefined
|
||||
if (session?.parentID) break
|
||||
try {
|
||||
errorPlayer?.play()
|
||||
} catch {}
|
||||
|
||||
playSound(soundSrc(settings.sounds.errors()))
|
||||
|
||||
const error = "error" in event.properties ? event.properties.error : undefined
|
||||
append({
|
||||
...base,
|
||||
@@ -121,9 +115,13 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||
session: sessionID ?? "global",
|
||||
error,
|
||||
})
|
||||
|
||||
const description = session?.title ?? (typeof error === "string" ? error : "An error occurred")
|
||||
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
|
||||
void platform.notify("Session error", description, href)
|
||||
if (settings.notifications.errors()) {
|
||||
void platform.notify("Session error", description, href)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { persisted } from "@/utils/persist"
|
||||
|
||||
export interface NotificationSettings {
|
||||
agent: boolean
|
||||
permissions: boolean
|
||||
errors: boolean
|
||||
}
|
||||
|
||||
export interface SoundSettings {
|
||||
agent: string
|
||||
permissions: string
|
||||
errors: string
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
general: {
|
||||
autoSave: boolean
|
||||
}
|
||||
appearance: {
|
||||
fontSize: number
|
||||
font: string
|
||||
}
|
||||
keybinds: Record<string, string>
|
||||
permissions: {
|
||||
autoApprove: boolean
|
||||
}
|
||||
notifications: NotificationSettings
|
||||
sounds: SoundSettings
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
general: {
|
||||
autoSave: true,
|
||||
},
|
||||
appearance: {
|
||||
fontSize: 14,
|
||||
font: "ibm-plex-mono",
|
||||
},
|
||||
keybinds: {},
|
||||
permissions: {
|
||||
autoApprove: false,
|
||||
},
|
||||
notifications: {
|
||||
agent: true,
|
||||
permissions: true,
|
||||
errors: false,
|
||||
},
|
||||
sounds: {
|
||||
agent: "staplebops-01",
|
||||
permissions: "staplebops-02",
|
||||
errors: "nope-03",
|
||||
},
|
||||
}
|
||||
|
||||
const monoFallback =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
|
||||
const monoFonts: Record<string, string> = {
|
||||
"ibm-plex-mono": `"IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"cascadia-code": `"Cascadia Code Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"fira-code": `"Fira Code Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
hack: `"Hack Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
inconsolata: `"Inconsolata Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"intel-one-mono": `"Intel One Mono Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"jetbrains-mono": `"JetBrains Mono Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"meslo-lgs": `"Meslo LGS Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"roboto-mono": `"Roboto Mono Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"source-code-pro": `"Source Code Pro Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"ubuntu-mono": `"Ubuntu Mono Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
}
|
||||
|
||||
export function monoFontFamily(font: string | undefined) {
|
||||
return monoFonts[font ?? defaultSettings.appearance.font] ?? monoFonts[defaultSettings.appearance.font]
|
||||
}
|
||||
|
||||
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
|
||||
name: "Settings",
|
||||
init: () => {
|
||||
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
get current() {
|
||||
return store
|
||||
},
|
||||
general: {
|
||||
autoSave: createMemo(() => store.general?.autoSave ?? defaultSettings.general.autoSave),
|
||||
setAutoSave(value: boolean) {
|
||||
setStore("general", "autoSave", value)
|
||||
},
|
||||
},
|
||||
appearance: {
|
||||
fontSize: createMemo(() => store.appearance?.fontSize ?? defaultSettings.appearance.fontSize),
|
||||
setFontSize(value: number) {
|
||||
setStore("appearance", "fontSize", value)
|
||||
},
|
||||
font: createMemo(() => store.appearance?.font ?? defaultSettings.appearance.font),
|
||||
setFont(value: string) {
|
||||
setStore("appearance", "font", value)
|
||||
},
|
||||
},
|
||||
keybinds: {
|
||||
get: (action: string) => store.keybinds?.[action],
|
||||
set(action: string, keybind: string) {
|
||||
setStore("keybinds", action, keybind)
|
||||
},
|
||||
reset(action: string) {
|
||||
setStore("keybinds", action, undefined!)
|
||||
},
|
||||
resetAll() {
|
||||
setStore("keybinds", reconcile({}))
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
autoApprove: createMemo(() => store.permissions?.autoApprove ?? defaultSettings.permissions.autoApprove),
|
||||
setAutoApprove(value: boolean) {
|
||||
setStore("permissions", "autoApprove", value)
|
||||
},
|
||||
},
|
||||
notifications: {
|
||||
agent: createMemo(() => store.notifications?.agent ?? defaultSettings.notifications.agent),
|
||||
setAgent(value: boolean) {
|
||||
setStore("notifications", "agent", value)
|
||||
},
|
||||
permissions: createMemo(() => store.notifications?.permissions ?? defaultSettings.notifications.permissions),
|
||||
setPermissions(value: boolean) {
|
||||
setStore("notifications", "permissions", value)
|
||||
},
|
||||
errors: createMemo(() => store.notifications?.errors ?? defaultSettings.notifications.errors),
|
||||
setErrors(value: boolean) {
|
||||
setStore("notifications", "errors", value)
|
||||
},
|
||||
},
|
||||
sounds: {
|
||||
agent: createMemo(() => store.sounds?.agent ?? defaultSettings.sounds.agent),
|
||||
setAgent(value: string) {
|
||||
setStore("sounds", "agent", value)
|
||||
},
|
||||
permissions: createMemo(() => store.sounds?.permissions ?? defaultSettings.sounds.permissions),
|
||||
setPermissions(value: string) {
|
||||
setStore("sounds", "permissions", value)
|
||||
},
|
||||
errors: createMemo(() => store.sounds?.errors ?? defaultSettings.sounds.errors),
|
||||
setErrors(value: string) {
|
||||
setStore("sounds", "errors", value)
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -38,6 +38,22 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, sess
|
||||
}),
|
||||
)
|
||||
|
||||
const unsub = sdk.event.on("pty.exited", (event) => {
|
||||
const id = event.properties.id
|
||||
if (!store.all.some((x) => x.id === id)) return
|
||||
batch(() => {
|
||||
setStore(
|
||||
"all",
|
||||
store.all.filter((x) => x.id !== id),
|
||||
)
|
||||
if (store.active === id) {
|
||||
const remaining = store.all.filter((x) => x.id !== id)
|
||||
setStore("active", remaining[0]?.id)
|
||||
}
|
||||
})
|
||||
})
|
||||
onCleanup(unsub)
|
||||
|
||||
return {
|
||||
ready,
|
||||
all: createMemo(() => Object.values(store.all)),
|
||||
|
||||
+247
-105
@@ -37,6 +37,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
import { Session, type Message, type TextPart } from "@opencode-ai/sdk/v2/client"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import {
|
||||
DragDropProvider,
|
||||
@@ -54,11 +55,13 @@ import { useNotification } from "@/context/notification"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { Binary } from "@opencode-ai/util/binary"
|
||||
import { retry } from "@opencode-ai/util/retry"
|
||||
import { playSound, soundSrc } from "@/utils/sound"
|
||||
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
|
||||
import { DialogSelectProvider } from "@/components/dialog-select-provider"
|
||||
import { DialogSelectServer } from "@/components/dialog-select-server"
|
||||
import { DialogSettings } from "@/components/dialog-settings"
|
||||
import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||
import { navStart } from "@/utils/perf"
|
||||
@@ -76,6 +79,7 @@ export default function Layout(props: ParentProps) {
|
||||
activeWorkspace: undefined as string | undefined,
|
||||
workspaceOrder: {} as Record<string, string[]>,
|
||||
workspaceName: {} as Record<string, string>,
|
||||
workspaceBranchName: {} as Record<string, Record<string, string>>,
|
||||
workspaceExpanded: {} as Record<string, boolean>,
|
||||
}),
|
||||
)
|
||||
@@ -96,6 +100,7 @@ export default function Layout(props: ParentProps) {
|
||||
const layout = useLayout()
|
||||
const layoutReady = createMemo(() => layout.ready())
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const server = useServer()
|
||||
const notification = useNotification()
|
||||
const permission = usePermission()
|
||||
@@ -117,15 +122,36 @@ export default function Layout(props: ParentProps) {
|
||||
active: "" as string,
|
||||
value: "",
|
||||
})
|
||||
const [busyWorkspaces, setBusyWorkspaces] = createSignal<Set<string>>(new Set())
|
||||
const setBusy = (directory: string, value: boolean) => {
|
||||
const key = workspaceKey(directory)
|
||||
setBusyWorkspaces((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (value) next.add(key)
|
||||
else next.delete(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
const isBusy = (directory: string) => busyWorkspaces().has(workspaceKey(directory))
|
||||
const editorRef = { current: undefined as HTMLInputElement | undefined }
|
||||
|
||||
const autoselecting = createMemo(() => {
|
||||
if (params.dir) return false
|
||||
if (initialDir) return false
|
||||
if (!autoselect()) return false
|
||||
if (!pageReady()) return true
|
||||
if (!layoutReady()) return true
|
||||
const list = layout.projects.list()
|
||||
if (list.length === 0) return false
|
||||
return true
|
||||
})
|
||||
|
||||
const editorOpen = (id: string) => editor.active === id
|
||||
const editorValue = () => editor.value
|
||||
|
||||
const openEditor = (id: string, value: string) => {
|
||||
if (!id) return
|
||||
setEditor({ active: id, value })
|
||||
queueMicrotask(() => editorRef.current?.focus())
|
||||
}
|
||||
|
||||
const closeEditor = () => setEditor({ active: "", value: "" })
|
||||
@@ -194,11 +220,15 @@ export default function Layout(props: ParentProps) {
|
||||
<InlineInput
|
||||
ref={(el) => {
|
||||
editorRef.current = el
|
||||
requestAnimationFrame(() => el.focus())
|
||||
}}
|
||||
value={editorValue()}
|
||||
class={props.class}
|
||||
onInput={(event) => setEditor("value", event.currentTarget.value)}
|
||||
onKeyDown={(event) => editorKeyDown(event, props.onSave)}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation()
|
||||
editorKeyDown(event, props.onSave)
|
||||
}}
|
||||
onBlur={() => closeEditor()}
|
||||
onPointerDown={stopPropagation}
|
||||
onClick={stopPropagation}
|
||||
@@ -313,7 +343,18 @@ export default function Layout(props: ParentProps) {
|
||||
if (now - lastAlerted < cooldownMs) return
|
||||
alertedAtBySession.set(sessionKey, now)
|
||||
|
||||
void platform.notify(config.title, description, href)
|
||||
if (e.details.type === "permission.asked") {
|
||||
playSound(soundSrc(settings.sounds.permissions()))
|
||||
if (settings.notifications.permissions()) {
|
||||
void platform.notify(config.title, description, href)
|
||||
}
|
||||
}
|
||||
|
||||
if (e.details.type === "question.asked") {
|
||||
if (settings.notifications.agent()) {
|
||||
void platform.notify(config.title, description, href)
|
||||
}
|
||||
}
|
||||
|
||||
const currentDir = params.dir ? base64Decode(params.dir) : undefined
|
||||
const currentSession = params.id
|
||||
@@ -458,9 +499,27 @@ export default function Layout(props: ParentProps) {
|
||||
),
|
||||
)
|
||||
|
||||
const workspaceName = (directory: string) => store.workspaceName[directory]
|
||||
const workspaceLabel = (directory: string, branch?: string) =>
|
||||
workspaceName(directory) ?? branch ?? getFilename(directory)
|
||||
const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "")
|
||||
|
||||
const workspaceName = (directory: string, projectId?: string, branch?: string) => {
|
||||
const key = workspaceKey(directory)
|
||||
const direct = store.workspaceName[key] ?? store.workspaceName[directory]
|
||||
if (direct) return direct
|
||||
if (!projectId) return
|
||||
if (!branch) return
|
||||
return store.workspaceBranchName[projectId]?.[branch]
|
||||
}
|
||||
|
||||
const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => {
|
||||
const key = workspaceKey(directory)
|
||||
setStore("workspaceName", (prev) => ({ ...(prev ?? {}), [key]: next }))
|
||||
if (!projectId) return
|
||||
if (!branch) return
|
||||
setStore("workspaceBranchName", projectId, (prev) => ({ ...(prev ?? {}), [branch]: next }))
|
||||
}
|
||||
|
||||
const workspaceLabel = (directory: string, branch?: string, projectId?: string) =>
|
||||
workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
|
||||
|
||||
const isWorkspaceEditing = () => editor.active.startsWith("workspace:")
|
||||
|
||||
@@ -515,9 +574,13 @@ export default function Layout(props: ParentProps) {
|
||||
if (!project) return [] as Session[]
|
||||
if (workspaceSetting()) {
|
||||
const dirs = workspaceIds(project)
|
||||
const activeDir = params.dir ? base64Decode(params.dir) : ""
|
||||
const result: Session[] = []
|
||||
for (const dir of dirs) {
|
||||
const [dirStore] = globalSync.child(dir)
|
||||
const expanded = store.workspaceExpanded[dir] ?? dir === project.worktree
|
||||
const active = dir === activeDir
|
||||
if (!expanded && !active) continue
|
||||
const [dirStore] = globalSync.child(dir, { bootstrap: true })
|
||||
const dirSessions = dirStore.session
|
||||
.filter((session) => session.directory === dirStore.path.directory)
|
||||
.filter((session) => !session.parentID && !session.time?.archived)
|
||||
@@ -847,6 +910,10 @@ export default function Layout(props: ParentProps) {
|
||||
dialog.show(() => <DialogSelectServer />)
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
dialog.show(() => <DialogSettings />)
|
||||
}
|
||||
|
||||
function navigateToProject(directory: string | undefined) {
|
||||
if (!directory) return
|
||||
server.projects.touch(directory)
|
||||
@@ -885,10 +952,10 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
}
|
||||
|
||||
const renameWorkspace = (directory: string, next: string) => {
|
||||
const current = workspaceName(directory) ?? getFilename(directory)
|
||||
const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => {
|
||||
const current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
|
||||
if (current === next) return
|
||||
setStore("workspaceName", directory, next)
|
||||
setWorkspaceName(directory, next, projectId, branch)
|
||||
}
|
||||
|
||||
function closeProject(directory: string) {
|
||||
@@ -939,6 +1006,8 @@ export default function Layout(props: ParentProps) {
|
||||
if (!current) return
|
||||
if (directory === current.worktree) return
|
||||
|
||||
setBusy(directory, true)
|
||||
|
||||
const result = await globalSDK.client.worktree
|
||||
.remove({ directory: current.worktree, worktreeRemoveInput: { directory } })
|
||||
.then((x) => x.data)
|
||||
@@ -950,6 +1019,8 @@ export default function Layout(props: ParentProps) {
|
||||
return false
|
||||
})
|
||||
|
||||
setBusy(directory, false)
|
||||
|
||||
if (!result) return
|
||||
|
||||
layout.projects.close(directory)
|
||||
@@ -965,12 +1036,7 @@ export default function Layout(props: ParentProps) {
|
||||
if (!current) return
|
||||
if (directory === current.worktree) return
|
||||
|
||||
const progress = showToast({
|
||||
persistent: true,
|
||||
title: "Resetting workspace",
|
||||
description: "This may take a minute.",
|
||||
})
|
||||
const dismiss = () => toaster.dismiss(progress)
|
||||
setBusy(directory, true)
|
||||
|
||||
const sessions = await globalSDK.client.session
|
||||
.list({ directory })
|
||||
@@ -981,7 +1047,6 @@ export default function Layout(props: ParentProps) {
|
||||
.reset({ directory: current.worktree, worktreeResetInput: { directory } })
|
||||
.then((x) => x.data)
|
||||
.catch((err) => {
|
||||
dismiss()
|
||||
showToast({
|
||||
title: "Failed to reset workspace",
|
||||
description: errorMessage(err),
|
||||
@@ -990,7 +1055,7 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
|
||||
if (!result) {
|
||||
dismiss()
|
||||
setBusy(directory, false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1010,7 +1075,8 @@ export default function Layout(props: ParentProps) {
|
||||
)
|
||||
|
||||
await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
|
||||
dismiss()
|
||||
|
||||
setBusy(directory, false)
|
||||
|
||||
const href = `/${base64Encode(directory)}/session`
|
||||
navigate(href)
|
||||
@@ -1186,8 +1252,12 @@ export default function Layout(props: ParentProps) {
|
||||
if (!project) return
|
||||
|
||||
if (workspaceSetting()) {
|
||||
const activeDir = params.dir ? base64Decode(params.dir) : ""
|
||||
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
|
||||
for (const directory of dirs) {
|
||||
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
|
||||
const active = directory === activeDir
|
||||
if (!expanded && !active) continue
|
||||
globalSync.project.loadSessions(directory)
|
||||
}
|
||||
return
|
||||
@@ -1411,7 +1481,7 @@ export default function Layout(props: ParentProps) {
|
||||
<div
|
||||
data-session-id={props.session.id}
|
||||
class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3
|
||||
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
|
||||
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
|
||||
>
|
||||
<Show
|
||||
when={hoverEnabled()}
|
||||
@@ -1421,7 +1491,7 @@ export default function Layout(props: ParentProps) {
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<HoverCard openDelay={150} closeDelay={100} placement="right" gutter={12} trigger={item}>
|
||||
<HoverCard openDelay={1000} closeDelay={0} placement="right-start" gutter={28} trigger={item}>
|
||||
<Show when={hoverReady()} fallback={<div class="text-12-regular text-text-weak">Loading messages…</div>}>
|
||||
<MessageNav
|
||||
messages={hoverMessages() ?? []}
|
||||
@@ -1491,7 +1561,7 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const [workspaceStore] = globalSync.child(directory)
|
||||
const kind = directory === project.worktree ? "local" : "sandbox"
|
||||
const name = workspaceLabel(directory, workspaceStore.vcs?.branch)
|
||||
const name = workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
|
||||
return `${kind} : ${name}`
|
||||
})
|
||||
|
||||
@@ -1506,8 +1576,9 @@ export default function Layout(props: ParentProps) {
|
||||
|
||||
const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => {
|
||||
const sortable = createSortable(props.directory)
|
||||
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory)
|
||||
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false })
|
||||
const [menuOpen, setMenuOpen] = createSignal(false)
|
||||
const [pendingRename, setPendingRename] = createSignal(false)
|
||||
const slug = createMemo(() => base64Encode(props.directory))
|
||||
const sessions = createMemo(() =>
|
||||
workspaceStore.session
|
||||
@@ -1516,13 +1587,20 @@ export default function Layout(props: ParentProps) {
|
||||
.toSorted(sortSessions),
|
||||
)
|
||||
const local = createMemo(() => props.directory === props.project.worktree)
|
||||
const workspaceValue = createMemo(() => {
|
||||
const name = workspaceStore.vcs?.branch ?? getFilename(props.directory)
|
||||
return workspaceName(props.directory) ?? name
|
||||
const active = createMemo(() => {
|
||||
const current = params.dir ? base64Decode(params.dir) : ""
|
||||
return current === props.directory
|
||||
})
|
||||
const open = createMemo(() => store.workspaceExpanded[props.directory] ?? true)
|
||||
const workspaceValue = createMemo(() => {
|
||||
const branch = workspaceStore.vcs?.branch
|
||||
const name = branch ?? getFilename(props.directory)
|
||||
return workspaceName(props.directory, props.project.id, branch) ?? name
|
||||
})
|
||||
const open = createMemo(() => store.workspaceExpanded[props.directory] ?? local())
|
||||
const boot = createMemo(() => open() || active())
|
||||
const loading = createMemo(() => open() && workspaceStore.status !== "complete" && sessions().length === 0)
|
||||
const hasMore = createMemo(() => local() && workspaceStore.sessionTotal > workspaceStore.session.length)
|
||||
const busy = createMemo(() => isBusy(props.directory))
|
||||
const loadMore = async () => {
|
||||
if (!local()) return
|
||||
setWorkspaceStore("limit", (limit) => limit + 5)
|
||||
@@ -1537,50 +1615,74 @@ export default function Layout(props: ParentProps) {
|
||||
if (editorOpen(`workspace:${props.directory}`)) closeEditor()
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!boot()) return
|
||||
globalSync.child(props.directory, { bootstrap: true })
|
||||
})
|
||||
|
||||
const header = () => (
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<div class="flex items-center justify-center shrink-0 size-6">
|
||||
<Show when={busy()} fallback={<Icon name="branch" size="small" />}>
|
||||
<Spinner class="size-[15px]" />
|
||||
</Show>
|
||||
</div>
|
||||
<span class="text-14-medium text-text-base shrink-0">{local() ? "local" : "sandbox"} :</span>
|
||||
<Show
|
||||
when={!local()}
|
||||
fallback={
|
||||
<span class="text-14-medium text-text-base min-w-0 truncate">
|
||||
{workspaceStore.vcs?.branch ?? getFilename(props.directory)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InlineEditor
|
||||
id={`workspace:${props.directory}`}
|
||||
value={workspaceValue}
|
||||
onSave={(next) => {
|
||||
const trimmed = next.trim()
|
||||
if (!trimmed) return
|
||||
renameWorkspace(props.directory, trimmed, props.project.id, workspaceStore.vcs?.branch)
|
||||
setEditor("value", workspaceValue())
|
||||
}}
|
||||
class="text-14-medium text-text-base min-w-0 truncate"
|
||||
displayClass="text-14-medium text-text-base min-w-0 truncate"
|
||||
editing={workspaceEditActive()}
|
||||
stopPropagation={false}
|
||||
openOnDblClick={false}
|
||||
/>
|
||||
</Show>
|
||||
<Icon
|
||||
name={open() ? "chevron-down" : "chevron-right"}
|
||||
size="small"
|
||||
class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/workspace:opacity-100 group-focus-within/workspace:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
||||
<div
|
||||
// @ts-ignore
|
||||
use:sortable
|
||||
classList={{
|
||||
"opacity-30": sortable.isActiveDraggable,
|
||||
"opacity-50 pointer-events-none": busy(),
|
||||
}}
|
||||
>
|
||||
<Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
|
||||
<div class="px-2 py-1">
|
||||
<div class="group/workspace relative">
|
||||
<div class="flex items-center gap-1">
|
||||
<Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<div class="flex items-center justify-center shrink-0 size-6">
|
||||
<Icon name="branch" size="small" />
|
||||
</div>
|
||||
<span class="text-14-medium text-text-base shrink-0">{local() ? "local" : "sandbox"} :</span>
|
||||
<Show
|
||||
when={!local()}
|
||||
fallback={
|
||||
<span class="text-14-medium text-text-base min-w-0 truncate">
|
||||
{workspaceStore.vcs?.branch ?? getFilename(props.directory)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InlineEditor
|
||||
id={`workspace:${props.directory}`}
|
||||
value={workspaceValue}
|
||||
onSave={(next) => {
|
||||
const trimmed = next.trim()
|
||||
if (!trimmed) return
|
||||
renameWorkspace(props.directory, trimmed)
|
||||
setEditor("value", workspaceValue())
|
||||
}}
|
||||
class="text-14-medium text-text-base min-w-0 truncate"
|
||||
displayClass="text-14-medium text-text-base min-w-0 truncate"
|
||||
editing={workspaceEditActive()}
|
||||
stopPropagation={false}
|
||||
openOnDblClick={false}
|
||||
/>
|
||||
</Show>
|
||||
<Icon
|
||||
name={open() ? "chevron-down" : "chevron-right"}
|
||||
size="small"
|
||||
class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/workspace:opacity-100 group-focus-within/workspace:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
</Collapsible.Trigger>
|
||||
<Show
|
||||
when={workspaceEditActive()}
|
||||
fallback={
|
||||
<Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover">
|
||||
{header()}
|
||||
</Collapsible.Trigger>
|
||||
}
|
||||
>
|
||||
<div class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md">{header()}</div>
|
||||
</Show>
|
||||
<div
|
||||
class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
|
||||
classList={{
|
||||
@@ -1595,21 +1697,37 @@ export default function Layout(props: ParentProps) {
|
||||
<DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" class="size-6 rounded-md" />
|
||||
</Tooltip>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Content
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!pendingRename()) return
|
||||
event.preventDefault()
|
||||
setPendingRename(false)
|
||||
openEditor(`workspace:${props.directory}`, workspaceValue())
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item onSelect={() => navigate(`/${slug()}/session`)}>
|
||||
<DropdownMenu.ItemLabel>New session</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
disabled={local()}
|
||||
onSelect={() => dialog.show(() => <DialogResetWorkspace directory={props.directory} />)}
|
||||
onSelect={() => {
|
||||
setPendingRename(true)
|
||||
setMenuOpen(false)
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>Reset workspace</DropdownMenu.ItemLabel>
|
||||
<DropdownMenu.ItemLabel>Rename</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
disabled={local()}
|
||||
disabled={local() || busy()}
|
||||
onSelect={() => dialog.show(() => <DialogResetWorkspace directory={props.directory} />)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>Reset</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
disabled={local() || busy()}
|
||||
onSelect={() => dialog.show(() => <DialogDeleteWorkspace directory={props.directory} />)}
|
||||
>
|
||||
<DropdownMenu.ItemLabel>Delete workspace</DropdownMenu.ItemLabel>
|
||||
<DropdownMenu.ItemLabel>Delete</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
@@ -1681,7 +1799,7 @@ export default function Layout(props: ParentProps) {
|
||||
const label = (directory: string) => {
|
||||
const [data] = globalSync.child(directory)
|
||||
const kind = directory === props.project.worktree ? "local" : "sandbox"
|
||||
const name = workspaceLabel(directory, data.vcs?.branch)
|
||||
const name = workspaceLabel(directory, data.vcs?.branch, props.project.id)
|
||||
return `${kind} : ${name}`
|
||||
}
|
||||
|
||||
@@ -1723,6 +1841,7 @@ export default function Layout(props: ParentProps) {
|
||||
// @ts-ignore
|
||||
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
|
||||
<HoverCard
|
||||
open={open()}
|
||||
openDelay={0}
|
||||
closeDelay={0}
|
||||
placement="right-start"
|
||||
@@ -1730,7 +1849,7 @@ export default function Layout(props: ParentProps) {
|
||||
trigger={trigger}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<div class="-m-3 flex flex-col w-72">
|
||||
<div class="-m-3 p-2 flex flex-col w-72">
|
||||
<div class="px-4 pt-2 pb-1 text-14-medium text-text-strong truncate">{displayName(props.project)}</div>
|
||||
<div class="px-4 pb-2 text-12-medium text-text-weak">Recent sessions</div>
|
||||
<div class="px-2 pb-2 flex flex-col gap-2">
|
||||
@@ -1775,20 +1894,22 @@ export default function Layout(props: ParentProps) {
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={!selected()}>
|
||||
<div class="px-2 py-2 border-t border-border-weak-base">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
|
||||
onClick={() => {
|
||||
layout.sidebar.open()
|
||||
navigateToProject(props.project.worktree)
|
||||
}}
|
||||
>
|
||||
View all sessions
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
<div class="px-2 py-2 border-t border-border-weak-base">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
|
||||
onClick={() => {
|
||||
if (selected()) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
layout.sidebar.open()
|
||||
navigateToProject(props.project.worktree)
|
||||
}}
|
||||
>
|
||||
View all sessions
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCard>
|
||||
</div>
|
||||
@@ -1880,6 +2001,17 @@ export default function Layout(props: ParentProps) {
|
||||
navigate(`/${base64Encode(created.directory)}/session`)
|
||||
}
|
||||
|
||||
command.register(() => [
|
||||
{
|
||||
id: "workspace.new",
|
||||
title: "New workspace",
|
||||
category: "Workspace",
|
||||
keybind: "mod+shift+w",
|
||||
disabled: !layout.sidebar.workspaces(project()?.worktree ?? "")(),
|
||||
onSelect: createWorkspace,
|
||||
},
|
||||
])
|
||||
|
||||
const homedir = createMemo(() => sync.data.path.home)
|
||||
|
||||
return (
|
||||
@@ -1920,8 +2052,8 @@ export default function Layout(props: ParentProps) {
|
||||
</DragDropProvider>
|
||||
</div>
|
||||
<div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2">
|
||||
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Settings" class="hidden">
|
||||
<IconButton disabled icon="settings-gear" variant="ghost" size="large" />
|
||||
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Settings">
|
||||
<IconButton icon="settings-gear" variant="ghost" size="large" onClick={openSettings} />
|
||||
</Tooltip>
|
||||
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Help">
|
||||
<IconButton
|
||||
@@ -1967,7 +2099,7 @@ export default function Layout(props: ParentProps) {
|
||||
transform: "translate3d(52px, 0, 0)",
|
||||
}}
|
||||
>
|
||||
<span class="text-12-regular text-text-base truncate">
|
||||
<span class="text-12-regular text-text-base truncate select-text">
|
||||
{project()?.worktree.replace(homedir(), "~")}
|
||||
</span>
|
||||
</Tooltip>
|
||||
@@ -2005,17 +2137,19 @@ export default function Layout(props: ParentProps) {
|
||||
fallback={
|
||||
<>
|
||||
<div class="py-4 px-3">
|
||||
<Button
|
||||
size="large"
|
||||
icon="plus-small"
|
||||
class="w-full"
|
||||
onClick={() => {
|
||||
navigate(`/${base64Encode(p.worktree)}/session`)
|
||||
layout.mobileSidebar.hide()
|
||||
}}
|
||||
>
|
||||
New session
|
||||
</Button>
|
||||
<TooltipKeybind title="New session" keybind={command.keybind("session.new")} placement="top">
|
||||
<Button
|
||||
size="large"
|
||||
icon="plus-small"
|
||||
class="w-full"
|
||||
onClick={() => {
|
||||
navigate(`/${base64Encode(p.worktree)}/session`)
|
||||
layout.mobileSidebar.hide()
|
||||
}}
|
||||
>
|
||||
New session
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0">
|
||||
<LocalWorkspace project={p} mobile={sidebarProps.mobile} />
|
||||
@@ -2025,9 +2159,15 @@ export default function Layout(props: ParentProps) {
|
||||
>
|
||||
<>
|
||||
<div class="py-4 px-3">
|
||||
<Button size="large" icon="plus-small" class="w-full" onClick={createWorkspace}>
|
||||
New workspace
|
||||
</Button>
|
||||
<TooltipKeybind
|
||||
title="New workspace"
|
||||
keybind={command.keybind("workspace.new")}
|
||||
placement="top"
|
||||
>
|
||||
<Button size="large" icon="plus-small" class="w-full" onClick={createWorkspace}>
|
||||
New workspace
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<div class="relative flex-1 min-h-0">
|
||||
<DragDropProvider
|
||||
@@ -2143,7 +2283,9 @@ export default function Layout(props: ParentProps) {
|
||||
"xl:border-l xl:rounded-tl-sm": !layout.sidebar.opened(),
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
<Toast.Region />
|
||||
|
||||
@@ -385,6 +385,19 @@ export default function Page() {
|
||||
terminal.new()
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => terminal.all().length,
|
||||
(count, prevCount) => {
|
||||
if (prevCount !== undefined && prevCount > 0 && count === 0) {
|
||||
if (view().terminal.opened()) {
|
||||
view().terminal.toggle()
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => visibleUserMessages().at(-1)?.id,
|
||||
@@ -456,7 +469,7 @@ export default function Page() {
|
||||
title: "New terminal",
|
||||
description: "Create a new terminal tab",
|
||||
category: "Terminal",
|
||||
keybind: "ctrl+shift+`",
|
||||
keybind: "ctrl+alt+t",
|
||||
onSelect: () => terminal.new(),
|
||||
},
|
||||
{
|
||||
@@ -824,10 +837,22 @@ export default function Page() {
|
||||
})
|
||||
|
||||
const isWorking = createMemo(() => status().type !== "idle")
|
||||
|
||||
const autoScroll = createAutoScroll({
|
||||
working: isWorking,
|
||||
working: () => true,
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
isWorking,
|
||||
(working, prev) => {
|
||||
if (!working || prev) return
|
||||
autoScroll.forceScrollToBottom()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
let scrollSpyFrame: number | undefined
|
||||
let scrollSpyTarget: HTMLDivElement | undefined
|
||||
|
||||
@@ -1248,9 +1273,9 @@ export default function Page() {
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="px-4 pt-18 pb-6 flex flex-col items-center justify-center text-center gap-3">
|
||||
<Mark class="w-6 opacity-40" />
|
||||
<div class="text-13-regular text-text-weak max-w-56">No changes in this session yet.</div>
|
||||
<div class="h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">No changes in this session yet</div>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
@@ -1340,10 +1365,6 @@ export default function Page() {
|
||||
classList={{
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200": !showTabs(),
|
||||
"last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-64px)]":
|
||||
platform.platform !== "desktop",
|
||||
"last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-64px)]":
|
||||
platform.platform === "desktop",
|
||||
}}
|
||||
>
|
||||
<SessionTurn
|
||||
@@ -1524,9 +1545,9 @@ export default function Page() {
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<div class="px-6 pt-18 pb-6 flex flex-col items-center justify-center text-center gap-3">
|
||||
<Mark class="w-6 opacity-40" />
|
||||
<div class="text-13-regular text-text-weak max-w-56">No changes in this session yet.</div>
|
||||
<div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
|
||||
<Mark class="w-14 opacity-10" />
|
||||
<div class="text-14-regular text-text-weak max-w-56">No changes in this session yet</div>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
@@ -16,6 +16,83 @@ type PersistTarget = {
|
||||
|
||||
const LEGACY_STORAGE = "default.dat"
|
||||
const GLOBAL_STORAGE = "opencode.global.dat"
|
||||
const LOCAL_PREFIX = "opencode."
|
||||
const fallback = { disabled: false }
|
||||
const cache = new Map<string, string>()
|
||||
|
||||
function quota(error: unknown) {
|
||||
if (error instanceof DOMException) {
|
||||
if (error.name === "QuotaExceededError") return true
|
||||
if (error.name === "NS_ERROR_DOM_QUOTA_REACHED") return true
|
||||
if (error.name === "QUOTA_EXCEEDED_ERR") return true
|
||||
if (error.code === 22 || error.code === 1014) return true
|
||||
return false
|
||||
}
|
||||
|
||||
if (!error || typeof error !== "object") return false
|
||||
const name = (error as { name?: string }).name
|
||||
if (name === "QuotaExceededError" || name === "NS_ERROR_DOM_QUOTA_REACHED") return true
|
||||
if (name && /quota/i.test(name)) return true
|
||||
|
||||
const code = (error as { code?: number }).code
|
||||
if (code === 22 || code === 1014) return true
|
||||
|
||||
const message = (error as { message?: string }).message
|
||||
if (typeof message !== "string") return false
|
||||
if (/quota/i.test(message)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
type Evict = { key: string; size: number }
|
||||
|
||||
function evict(storage: Storage, keep: string, value: string) {
|
||||
const total = storage.length
|
||||
const indexes = Array.from({ length: total }, (_, index) => index)
|
||||
const items: Evict[] = []
|
||||
|
||||
for (const index of indexes) {
|
||||
const name = storage.key(index)
|
||||
if (!name) continue
|
||||
if (!name.startsWith(LOCAL_PREFIX)) continue
|
||||
if (name === keep) continue
|
||||
const stored = storage.getItem(name)
|
||||
items.push({ key: name, size: stored?.length ?? 0 })
|
||||
}
|
||||
|
||||
items.sort((a, b) => b.size - a.size)
|
||||
|
||||
for (const item of items) {
|
||||
storage.removeItem(item.key)
|
||||
|
||||
try {
|
||||
storage.setItem(keep, value)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (!quota(error)) throw error
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function write(storage: Storage, key: string, value: string) {
|
||||
try {
|
||||
storage.setItem(key, value)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (!quota(error)) throw error
|
||||
}
|
||||
|
||||
try {
|
||||
storage.removeItem(key)
|
||||
storage.setItem(key, value)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (!quota(error)) throw error
|
||||
}
|
||||
|
||||
return evict(storage, key, value)
|
||||
}
|
||||
|
||||
function snapshot(value: unknown) {
|
||||
return JSON.parse(JSON.stringify(value)) as unknown
|
||||
@@ -67,10 +144,66 @@ function workspaceStorage(dir: string) {
|
||||
|
||||
function localStorageWithPrefix(prefix: string): SyncStorage {
|
||||
const base = `${prefix}:`
|
||||
const item = (key: string) => base + key
|
||||
return {
|
||||
getItem: (key) => localStorage.getItem(base + key),
|
||||
setItem: (key, value) => localStorage.setItem(base + key, value),
|
||||
removeItem: (key) => localStorage.removeItem(base + key),
|
||||
getItem: (key) => {
|
||||
const name = item(key)
|
||||
const cached = cache.get(name)
|
||||
if (fallback.disabled && cached !== undefined) return cached
|
||||
|
||||
const stored = localStorage.getItem(name)
|
||||
if (stored === null) return cached ?? null
|
||||
cache.set(name, stored)
|
||||
return stored
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
const name = item(key)
|
||||
cache.set(name, value)
|
||||
if (fallback.disabled) return
|
||||
try {
|
||||
if (write(localStorage, name, value)) return
|
||||
} catch {
|
||||
fallback.disabled = true
|
||||
return
|
||||
}
|
||||
fallback.disabled = true
|
||||
},
|
||||
removeItem: (key) => {
|
||||
const name = item(key)
|
||||
cache.delete(name)
|
||||
if (fallback.disabled) return
|
||||
localStorage.removeItem(name)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function localStorageDirect(): SyncStorage {
|
||||
return {
|
||||
getItem: (key) => {
|
||||
const cached = cache.get(key)
|
||||
if (fallback.disabled && cached !== undefined) return cached
|
||||
|
||||
const stored = localStorage.getItem(key)
|
||||
if (stored === null) return cached ?? null
|
||||
cache.set(key, stored)
|
||||
return stored
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
cache.set(key, value)
|
||||
if (fallback.disabled) return
|
||||
try {
|
||||
if (write(localStorage, key, value)) return
|
||||
} catch {
|
||||
fallback.disabled = true
|
||||
return
|
||||
}
|
||||
fallback.disabled = true
|
||||
},
|
||||
removeItem: (key) => {
|
||||
cache.delete(key)
|
||||
if (fallback.disabled) return
|
||||
localStorage.removeItem(key)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +232,7 @@ export function removePersisted(target: { storage?: string; key: string }) {
|
||||
}
|
||||
|
||||
if (!target.storage) {
|
||||
localStorage.removeItem(target.key)
|
||||
localStorageDirect().removeItem(target.key)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -120,12 +253,12 @@ export function persisted<T>(
|
||||
|
||||
const currentStorage = (() => {
|
||||
if (isDesktop) return platform.storage?.(config.storage)
|
||||
if (!config.storage) return localStorage
|
||||
if (!config.storage) return localStorageDirect()
|
||||
return localStorageWithPrefix(config.storage)
|
||||
})()
|
||||
|
||||
const legacyStorage = (() => {
|
||||
if (!isDesktop) return localStorage
|
||||
if (!isDesktop) return localStorageDirect()
|
||||
if (!config.storage) return platform.storage?.()
|
||||
return platform.storage?.(LEGACY_STORAGE)
|
||||
})()
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import nope01 from "@opencode-ai/ui/audio/nope-01.aac"
|
||||
import nope02 from "@opencode-ai/ui/audio/nope-02.aac"
|
||||
import nope03 from "@opencode-ai/ui/audio/nope-03.aac"
|
||||
import nope04 from "@opencode-ai/ui/audio/nope-04.aac"
|
||||
import nope05 from "@opencode-ai/ui/audio/nope-05.aac"
|
||||
import staplebops01 from "@opencode-ai/ui/audio/staplebops-01.aac"
|
||||
import staplebops02 from "@opencode-ai/ui/audio/staplebops-02.aac"
|
||||
import staplebops03 from "@opencode-ai/ui/audio/staplebops-03.aac"
|
||||
import staplebops04 from "@opencode-ai/ui/audio/staplebops-04.aac"
|
||||
import staplebops05 from "@opencode-ai/ui/audio/staplebops-05.aac"
|
||||
import staplebops06 from "@opencode-ai/ui/audio/staplebops-06.aac"
|
||||
import staplebops07 from "@opencode-ai/ui/audio/staplebops-07.aac"
|
||||
|
||||
export const SOUND_OPTIONS = [
|
||||
{ id: "staplebops-01", label: "Boopy", src: staplebops01 },
|
||||
{ id: "staplebops-02", label: "Beepy", src: staplebops02 },
|
||||
{ id: "staplebops-03", label: "Staplebops 03", src: staplebops03 },
|
||||
{ id: "staplebops-04", label: "Staplebops 04", src: staplebops04 },
|
||||
{ id: "staplebops-05", label: "Staplebops 05", src: staplebops05 },
|
||||
{ id: "staplebops-06", label: "Staplebops 06", src: staplebops06 },
|
||||
{ id: "staplebops-07", label: "Staplebops 07", src: staplebops07 },
|
||||
{ id: "nope-01", label: "Nope 01", src: nope01 },
|
||||
{ id: "nope-02", label: "Nope 02", src: nope02 },
|
||||
{ id: "nope-03", label: "Oopsie", src: nope03 },
|
||||
{ id: "nope-04", label: "Nope 04", src: nope04 },
|
||||
{ id: "nope-05", label: "Nope 05", src: nope05 },
|
||||
] as const
|
||||
|
||||
export type SoundOption = (typeof SOUND_OPTIONS)[number]
|
||||
export type SoundID = SoundOption["id"]
|
||||
|
||||
const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record<SoundID, string>
|
||||
|
||||
export function soundSrc(id: string | undefined) {
|
||||
if (!id) return
|
||||
if (!(id in soundById)) return
|
||||
return soundById[id as SoundID]
|
||||
}
|
||||
|
||||
export function playSound(src: string | undefined) {
|
||||
if (typeof Audio === "undefined") return
|
||||
if (!src) return
|
||||
void new Audio(src).play().catch(() => undefined)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"url": "/",
|
||||
"decorations": true,
|
||||
"dragDropEnabled": false,
|
||||
"zoomHotkeysEnabled": true,
|
||||
"zoomHotkeysEnabled": false,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
"trafficLightPosition": { "x": 12.0, "y": 18.0 }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @refresh reload
|
||||
import "./webview-zoom"
|
||||
import { render } from "solid-js/web"
|
||||
import { AppBaseProviders, AppInterface, PlatformProvider, Platform } from "@opencode-ai/app"
|
||||
import { open, save } from "@tauri-apps/plugin-dialog"
|
||||
@@ -94,6 +95,21 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
|
||||
const apiCache = new Map<string, AsyncStorage & { flush: () => Promise<void> }>()
|
||||
const memoryCache = new Map<string, StoreLike>()
|
||||
|
||||
const flushAll = async () => {
|
||||
const apis = Array.from(apiCache.values())
|
||||
await Promise.all(apis.map((api) => api.flush().catch(() => undefined)))
|
||||
}
|
||||
|
||||
if ("addEventListener" in globalThis) {
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState !== "hidden") return
|
||||
void flushAll()
|
||||
}
|
||||
|
||||
window.addEventListener("pagehide", () => void flushAll())
|
||||
document.addEventListener("visibilitychange", handleVisibility)
|
||||
}
|
||||
|
||||
const createMemoryStore = () => {
|
||||
const data = new Map<string, string>()
|
||||
const store: StoreLike = {
|
||||
@@ -303,11 +319,6 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
|
||||
|
||||
createMenu()
|
||||
|
||||
// Stops mousewheel events from reaching Tauri's pinch-to-zoom handler
|
||||
root?.addEventListener("mousewheel", (e) => {
|
||||
e.stopPropagation()
|
||||
})
|
||||
|
||||
render(() => {
|
||||
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
|
||||
const platform = createPlatform(() => serverPassword())
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/menu"
|
||||
import { type as ostype } from "@tauri-apps/plugin-os"
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { relaunch } from "@tauri-apps/plugin-process"
|
||||
|
||||
import { runUpdater, UPDATER_ENABLED } from "./updater"
|
||||
import { installCli } from "./cli"
|
||||
@@ -24,6 +26,17 @@ export async function createMenu() {
|
||||
action: () => installCli(),
|
||||
text: "Install CLI...",
|
||||
}),
|
||||
await MenuItem.new({
|
||||
action: async () => window.location.reload(),
|
||||
text: "Reload Webview",
|
||||
}),
|
||||
await MenuItem.new({
|
||||
action: async () => {
|
||||
await invoke("kill_sidecar").catch(() => undefined)
|
||||
await relaunch().catch(() => undefined)
|
||||
},
|
||||
text: "Restart",
|
||||
}),
|
||||
await PredefinedMenuItem.new({
|
||||
item: "Separator",
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core"
|
||||
import { type as ostype } from "@tauri-apps/plugin-os"
|
||||
|
||||
const OS_NAME = ostype()
|
||||
|
||||
let zoomLevel = 1
|
||||
|
||||
const MAX_ZOOM_LEVEL = 10
|
||||
const MIN_ZOOM_LEVEL = 0.2
|
||||
|
||||
window.addEventListener("keydown", (event) => {
|
||||
if (OS_NAME === "macos" ? event.metaKey : event.ctrlKey) {
|
||||
if (event.key === "-") {
|
||||
zoomLevel -= 0.2
|
||||
} else if (event.key === "=" || event.key === "+") {
|
||||
zoomLevel += 0.2
|
||||
} else if (event.key === "0") {
|
||||
zoomLevel = 1
|
||||
} else {
|
||||
return
|
||||
}
|
||||
zoomLevel = Math.min(Math.max(zoomLevel, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
|
||||
invoke("plugin:webview|set_webview_zoom", {
|
||||
value: zoomLevel,
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.1.26"
|
||||
version = "1.1.28"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.26/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.28/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.26/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.28/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.26/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.28/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.26/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.28/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.26/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.28/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type PermissionOption,
|
||||
type PlanEntry,
|
||||
type PromptRequest,
|
||||
type Role,
|
||||
type SetSessionModelRequest,
|
||||
type SetSessionModeRequest,
|
||||
type SetSessionModeResponse,
|
||||
@@ -687,7 +688,8 @@ export namespace ACP {
|
||||
break
|
||||
}
|
||||
} else if (part.type === "text") {
|
||||
if (part.text && !part.ignored) {
|
||||
if (part.text) {
|
||||
const audience: Role[] | undefined = part.synthetic ? ["assistant"] : part.ignored ? ["user"] : undefined
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
@@ -696,6 +698,7 @@ export namespace ACP {
|
||||
content: {
|
||||
type: "text",
|
||||
text: part.text,
|
||||
...(audience && { annotations: { audience } }),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -968,14 +971,20 @@ export namespace ACP {
|
||||
const agent = session.modeId ?? (await AgentModule.defaultAgent())
|
||||
|
||||
const parts: Array<
|
||||
{ type: "text"; text: string } | { type: "file"; url: string; filename: string; mime: string }
|
||||
| { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
|
||||
| { type: "file"; url: string; filename: string; mime: string }
|
||||
> = []
|
||||
for (const part of params.prompt) {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
const audience = part.annotations?.audience
|
||||
const forAssistant = audience?.length === 1 && audience[0] === "assistant"
|
||||
const forUser = audience?.length === 1 && audience[0] === "user"
|
||||
parts.push({
|
||||
type: "text" as const,
|
||||
text: part.text,
|
||||
...(forAssistant && { synthetic: true }),
|
||||
...(forUser && { ignored: true }),
|
||||
})
|
||||
break
|
||||
case "image": {
|
||||
|
||||
@@ -134,7 +134,7 @@ const AgentCreateCommand = cmd({
|
||||
selectedTools = cliTools ? cliTools.split(",").map((t) => t.trim()) : AVAILABLE_TOOLS
|
||||
} else {
|
||||
const result = await prompts.multiselect({
|
||||
message: "Select tools to enable",
|
||||
message: "Select tools to enable (Space to toggle)",
|
||||
options: AVAILABLE_TOOLS.map((tool) => ({
|
||||
label: tool,
|
||||
value: tool,
|
||||
|
||||
@@ -546,16 +546,22 @@ export function Prompt(props: PromptProps) {
|
||||
} else if (
|
||||
inputText.startsWith("/") &&
|
||||
iife(() => {
|
||||
const command = inputText.split(" ")[0].slice(1)
|
||||
console.log(command)
|
||||
const firstLine = inputText.split("\n")[0]
|
||||
const command = firstLine.split(" ")[0].slice(1)
|
||||
return sync.data.command.some((x) => x.name === command)
|
||||
})
|
||||
) {
|
||||
let [command, ...args] = inputText.split(" ")
|
||||
// Parse command from first line, preserve multi-line content in arguments
|
||||
const firstLineEnd = inputText.indexOf("\n")
|
||||
const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd)
|
||||
const [command, ...firstLineArgs] = firstLine.split(" ")
|
||||
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
|
||||
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
|
||||
|
||||
sdk.client.session.command({
|
||||
sessionID,
|
||||
command: command.slice(1),
|
||||
arguments: args.join(" "),
|
||||
arguments: args,
|
||||
agent: local.agent.current().name,
|
||||
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
|
||||
messageID,
|
||||
|
||||
@@ -241,9 +241,27 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
||||
event.properties.info.sessionID,
|
||||
produce((draft) => {
|
||||
draft.splice(result.index, 0, event.properties.info)
|
||||
if (draft.length > 100) draft.shift()
|
||||
}),
|
||||
)
|
||||
const updated = store.message[event.properties.info.sessionID]
|
||||
if (updated.length > 100) {
|
||||
const oldest = updated[0]
|
||||
batch(() => {
|
||||
setStore(
|
||||
"message",
|
||||
event.properties.info.sessionID,
|
||||
produce((draft) => {
|
||||
draft.shift()
|
||||
}),
|
||||
)
|
||||
setStore(
|
||||
"part",
|
||||
produce((draft) => {
|
||||
delete draft[oldest.id]
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
case "message.removed": {
|
||||
|
||||
@@ -73,6 +73,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
||||
<box
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
width={42}
|
||||
height="100%"
|
||||
paddingTop={1}
|
||||
paddingBottom={1}
|
||||
paddingLeft={2}
|
||||
|
||||
@@ -12,7 +12,13 @@ import { lazy } from "../util/lazy"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Auth } from "../auth"
|
||||
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
|
||||
import {
|
||||
type ParseError as JsoncParseError,
|
||||
applyEdits,
|
||||
modify,
|
||||
parse as parseJsonc,
|
||||
printParseErrorCode,
|
||||
} from "jsonc-parser"
|
||||
import { Instance } from "../project/instance"
|
||||
import { LSPServer } from "../lsp/server"
|
||||
import { BunProc } from "@/bun"
|
||||
@@ -20,6 +26,8 @@ import { Installation } from "@/installation"
|
||||
import { ConfigMarkdown } from "./markdown"
|
||||
import { existsSync } from "fs"
|
||||
import { Bus } from "@/bus"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Event } from "../server/event"
|
||||
|
||||
export namespace Config {
|
||||
const log = Log.create({ service: "config" })
|
||||
@@ -1242,6 +1250,10 @@ export namespace Config {
|
||||
return state().then((x) => x.config)
|
||||
}
|
||||
|
||||
export async function getGlobal() {
|
||||
return global()
|
||||
}
|
||||
|
||||
export async function update(config: Info) {
|
||||
const filepath = path.join(Instance.directory, "config.json")
|
||||
const existing = await loadFile(filepath)
|
||||
@@ -1249,6 +1261,100 @@ export namespace Config {
|
||||
await Instance.dispose()
|
||||
}
|
||||
|
||||
function globalConfigFile() {
|
||||
const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) =>
|
||||
path.join(Global.Path.config, file),
|
||||
)
|
||||
for (const file of candidates) {
|
||||
if (existsSync(file)) return file
|
||||
}
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function patchJsonc(input: string, patch: unknown, path: string[] = []): string {
|
||||
if (!isRecord(patch)) {
|
||||
const edits = modify(input, path, patch, {
|
||||
formattingOptions: {
|
||||
insertSpaces: true,
|
||||
tabSize: 2,
|
||||
},
|
||||
})
|
||||
return applyEdits(input, edits)
|
||||
}
|
||||
|
||||
return Object.entries(patch).reduce((result, [key, value]) => {
|
||||
if (value === undefined) return result
|
||||
return patchJsonc(result, value, [...path, key])
|
||||
}, input)
|
||||
}
|
||||
|
||||
function parseConfig(text: string, filepath: string): Info {
|
||||
const errors: JsoncParseError[] = []
|
||||
const data = parseJsonc(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length) {
|
||||
const lines = text.split("\n")
|
||||
const errorDetails = errors
|
||||
.map((e) => {
|
||||
const beforeOffset = text.substring(0, e.offset).split("\n")
|
||||
const line = beforeOffset.length
|
||||
const column = beforeOffset[beforeOffset.length - 1].length + 1
|
||||
const problemLine = lines[line - 1]
|
||||
|
||||
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
|
||||
if (!problemLine) return error
|
||||
|
||||
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
|
||||
})
|
||||
.join("\n")
|
||||
|
||||
throw new JsonError({
|
||||
path: filepath,
|
||||
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
|
||||
})
|
||||
}
|
||||
|
||||
const parsed = Info.safeParse(data)
|
||||
if (parsed.success) return parsed.data
|
||||
|
||||
throw new InvalidError({
|
||||
path: filepath,
|
||||
issues: parsed.error.issues,
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateGlobal(config: Info) {
|
||||
const filepath = globalConfigFile()
|
||||
const before = await Bun.file(filepath)
|
||||
.text()
|
||||
.catch((err) => {
|
||||
if (err.code === "ENOENT") return "{}"
|
||||
throw new JsonError({ path: filepath }, { cause: err })
|
||||
})
|
||||
|
||||
if (!filepath.endsWith(".jsonc")) {
|
||||
const existing = parseConfig(before, filepath)
|
||||
await Bun.write(filepath, JSON.stringify(mergeDeep(existing, config), null, 2))
|
||||
} else {
|
||||
const next = patchJsonc(before, config)
|
||||
parseConfig(next, filepath)
|
||||
await Bun.write(filepath, next)
|
||||
}
|
||||
|
||||
global.reset()
|
||||
await Instance.disposeAll()
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: {
|
||||
type: Event.Disposed.type,
|
||||
properties: {},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function directories() {
|
||||
return state().then((x) => x.directories)
|
||||
}
|
||||
|
||||
@@ -32,11 +32,16 @@ export namespace FileWatcher {
|
||||
),
|
||||
}
|
||||
|
||||
const watcher = lazy(() => {
|
||||
const binding = require(
|
||||
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
|
||||
)
|
||||
return createWrapper(binding) as typeof import("@parcel/watcher")
|
||||
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
|
||||
try {
|
||||
const binding = require(
|
||||
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
|
||||
)
|
||||
return createWrapper(binding) as typeof import("@parcel/watcher")
|
||||
} catch (error) {
|
||||
log.error("failed to load watcher binding", { error })
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
const state = Instance.state(
|
||||
@@ -54,6 +59,10 @@ export namespace FileWatcher {
|
||||
return {}
|
||||
}
|
||||
log.info("watcher backend", { platform: process.platform, backend })
|
||||
|
||||
const w = watcher()
|
||||
if (!w) return {}
|
||||
|
||||
const subscribe: ParcelWatcher.SubscribeCallback = (err, evts) => {
|
||||
if (err) return
|
||||
for (const evt of evts) {
|
||||
@@ -67,7 +76,7 @@ export namespace FileWatcher {
|
||||
const cfgIgnores = cfg.watcher?.ignore ?? []
|
||||
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
||||
const pending = watcher().subscribe(Instance.directory, subscribe, {
|
||||
const pending = w.subscribe(Instance.directory, subscribe, {
|
||||
ignore: [...FileIgnore.PATTERNS, ...cfgIgnores],
|
||||
backend,
|
||||
})
|
||||
@@ -89,7 +98,7 @@ export namespace FileWatcher {
|
||||
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
|
||||
const gitDirContents = await readdir(vcsDir).catch(() => [])
|
||||
const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD")
|
||||
const pending = watcher().subscribe(vcsDir, subscribe, {
|
||||
const pending = w.subscribe(vcsDir, subscribe, {
|
||||
ignore: ignoreList,
|
||||
backend,
|
||||
})
|
||||
|
||||
@@ -795,6 +795,11 @@ export namespace MCP {
|
||||
// The SDK has already added the state parameter to the authorization URL
|
||||
// We just need to open the browser
|
||||
log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState })
|
||||
|
||||
// Register the callback BEFORE opening the browser to avoid race condition
|
||||
// when the IdP has an active SSO session and redirects immediately
|
||||
const callbackPromise = McpOAuthCallback.waitForCallback(oauthState)
|
||||
|
||||
try {
|
||||
const subprocess = await open(authorizationUrl)
|
||||
// The open package spawns a detached process and returns immediately.
|
||||
@@ -822,8 +827,8 @@ export namespace MCP {
|
||||
Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl })
|
||||
}
|
||||
|
||||
// Wait for callback using the OAuth state parameter
|
||||
const code = await McpOAuthCallback.waitForCallback(oauthState)
|
||||
// Wait for callback using the already-registered promise
|
||||
const code = await callbackPromise
|
||||
|
||||
// Validate and clear the state
|
||||
const storedState = await McpAuth.getOAuthState(mcpName)
|
||||
|
||||
@@ -147,6 +147,9 @@ export namespace Pty {
|
||||
log.info("session exited", { id, exitCode })
|
||||
session.info.status = "exited"
|
||||
Bus.publish(Event.Exited, { id, exitCode })
|
||||
for (const ws of session.subscribers) {
|
||||
ws.close()
|
||||
}
|
||||
state().delete(id)
|
||||
})
|
||||
Bus.publish(Event.Created, { info })
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import z from "zod"
|
||||
|
||||
export const Event = {
|
||||
Connected: BusEvent.define("server.connected", z.object({})),
|
||||
Disposed: BusEvent.define("global.disposed", z.object({})),
|
||||
}
|
||||
@@ -54,11 +54,6 @@ export namespace Server {
|
||||
return _url ?? new URL("http://localhost:4096")
|
||||
}
|
||||
|
||||
export const Event = {
|
||||
Connected: BusEvent.define("server.connected", z.object({})),
|
||||
Disposed: BusEvent.define("global.disposed", z.object({})),
|
||||
}
|
||||
|
||||
const app = new Hono()
|
||||
export const App: () => Hono = lazy(
|
||||
() =>
|
||||
|
||||
@@ -176,7 +176,7 @@ export namespace SessionProcessor {
|
||||
...match,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: value.input,
|
||||
input: value.input ?? match.state.input,
|
||||
output: value.output.output,
|
||||
metadata: value.output.metadata,
|
||||
title: value.output.title,
|
||||
@@ -200,7 +200,7 @@ export namespace SessionProcessor {
|
||||
...match,
|
||||
state: {
|
||||
status: "error",
|
||||
input: value.input,
|
||||
input: value.input ?? match.state.input,
|
||||
error: (value.error as any).toString(),
|
||||
time: {
|
||||
start: match.state.time.start,
|
||||
|
||||
@@ -44,6 +44,7 @@ import { SessionStatus } from "./status"
|
||||
import { LLM } from "./llm"
|
||||
import { iife } from "@/util/iife"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Truncate } from "@/tool/truncation"
|
||||
|
||||
// @ts-ignore
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
@@ -801,10 +802,17 @@ export namespace SessionPrompt {
|
||||
}
|
||||
}
|
||||
|
||||
const truncated = await Truncate.output(textParts.join("\n\n"), {}, input.agent)
|
||||
const metadata = {
|
||||
...(result.metadata ?? {}),
|
||||
truncated: truncated.truncated,
|
||||
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||
}
|
||||
|
||||
return {
|
||||
title: "",
|
||||
metadata: result.metadata ?? {},
|
||||
output: textParts.join("\n\n"),
|
||||
metadata,
|
||||
output: truncated.content,
|
||||
attachments,
|
||||
content: result.content, // directly return content to preserve ordering when outputting to model
|
||||
}
|
||||
@@ -1619,8 +1627,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
if (position === last) return args.slice(argIndex).join(" ")
|
||||
return args[argIndex]
|
||||
})
|
||||
const usesArgumentsPlaceholder = templateCommand.includes("$ARGUMENTS")
|
||||
let template = withArgs.replaceAll("$ARGUMENTS", input.arguments)
|
||||
|
||||
// If command doesn't explicitly handle arguments (no $N or $ARGUMENTS placeholders)
|
||||
// but user provided arguments, append them to the template
|
||||
if (placeholders.length === 0 && !usesArgumentsPlaceholder && input.arguments.trim()) {
|
||||
template = template + "\n\n" + input.arguments
|
||||
}
|
||||
|
||||
const shell = ConfigMarkdown.shell(template)
|
||||
if (shell.length > 0) {
|
||||
const results = await Promise.all(
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
You are OpenCode, the best coding agent on the planet.
|
||||
|
||||
You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
|
||||
|
||||
## Editing constraints
|
||||
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
|
||||
- Only add comments if they are necessary to make a non-obvious block easier to understand.
|
||||
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
|
||||
|
||||
## Tool usage
|
||||
- Prefer specialized tools over shell for file operations:
|
||||
- Use Read to view files, Edit to modify files, and Write only when needed.
|
||||
- Use Glob to find files by name and Grep to search file contents.
|
||||
- Use Bash for terminal operations (git, bun, builds, tests, running scripts).
|
||||
- Run tool calls in parallel when neither call needs the other’s output; otherwise run sequentially.
|
||||
|
||||
## Git and workspace hygiene
|
||||
- You may be in a dirty git worktree.
|
||||
* NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.
|
||||
* If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.
|
||||
* If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.
|
||||
* If the changes are in unrelated files, just ignore them and don't revert them.
|
||||
- Do not amend commits unless explicitly requested.
|
||||
- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.
|
||||
|
||||
## Frontend tasks
|
||||
When doing frontend design tasks, avoid collapsing into bland, generic layouts.
|
||||
Aim for interfaces that feel intentional and deliberate.
|
||||
- Typography: Use expressive, purposeful fonts and avoid default stacks (Inter, Roboto, Arial, system).
|
||||
- Color & Look: Choose a clear visual direction; define CSS variables; avoid purple-on-white defaults. No purple bias or dark mode bias.
|
||||
- Motion: Use a few meaningful animations (page-load, staggered reveals) instead of generic micro-motions.
|
||||
- Background: Don't rely on flat, single-color backgrounds; use gradients, shapes, or subtle patterns to build atmosphere.
|
||||
- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.
|
||||
- Ensure the page loads properly on both desktop and mobile.
|
||||
|
||||
Exception: If working within an existing website or design system, preserve the established patterns, structure, and visual language.
|
||||
|
||||
## Presenting your work and final message
|
||||
|
||||
You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.
|
||||
|
||||
- Default: be very concise; friendly coding teammate tone.
|
||||
- Ask only when needed; suggest ideas; mirror the user's style.
|
||||
- For substantial work, summarize clearly; follow final‑answer formatting.
|
||||
- Skip heavy formatting for simple confirmations.
|
||||
- Don't dump large files you've written; reference paths only.
|
||||
- No "save/copy this file" - User is on the same machine.
|
||||
- Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something.
|
||||
- For code changes:
|
||||
* Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with "summary", just jump right in.
|
||||
* If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps.
|
||||
* When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number.
|
||||
- The user does not command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.
|
||||
|
||||
## Final answer structure and style guidelines
|
||||
|
||||
- Plain text; CLI handles styling. Use structure only when it helps scanability.
|
||||
- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help.
|
||||
- Bullets: use - ; merge related points; keep to one line when possible; 4–6 per list ordered by importance; keep phrasing consistent.
|
||||
- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **.
|
||||
- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible.
|
||||
- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task.
|
||||
- Tone: collaborative, concise, factual; present tense, active voice; self‑contained; no "above/below"; parallel wording.
|
||||
- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers.
|
||||
- Adaptation: code explanations → precise, structured with code refs; simple tasks → lead with outcome; big changes → logical walkthrough + rationale + next actions; casual one-offs → plain sentences, no headers/bullets.
|
||||
- File References: When referencing files in your response follow the below rules:
|
||||
* Use inline code to make file paths clickable.
|
||||
* Each reference should have a stand alone path. Even if it's the same file.
|
||||
* Accepted: absolute, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix.
|
||||
* Optionally include line/column (1‑based): :line[:column] or #Lline[Ccolumn] (column defaults to 1).
|
||||
* Do not use URIs like file://, vscode://, or https://.
|
||||
* Do not provide range of lines
|
||||
* Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5
|
||||
@@ -5,6 +5,7 @@ You are an interactive CLI tool that helps users with software engineering tasks
|
||||
## Editing constraints
|
||||
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
|
||||
- Only add comments if they are necessary to make a non-obvious block easier to understand.
|
||||
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
|
||||
|
||||
## Tool usage
|
||||
- Prefer specialized tools over shell for file operations:
|
||||
@@ -39,7 +40,13 @@ Exception: If working within an existing website or design system, preserve the
|
||||
You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.
|
||||
|
||||
- Default: be very concise; friendly coding teammate tone.
|
||||
- Ask only when needed; suggest ideas; mirror the user's style.
|
||||
- Default: do the work without asking questions. Treat short tasks as sufficient direction; infer missing details by reading the codebase and following existing conventions.
|
||||
- Questions: only ask when you are truly blocked after checking relevant context AND you cannot safely pick a reasonable default. This usually means one of:
|
||||
* The request is ambiguous in a way that materially changes the result and you cannot disambiguate by reading the repo.
|
||||
* The action is destructive/irreversible, touches production, or changes billing/security posture.
|
||||
* You need a secret/credential/value that cannot be inferred (API key, account id, etc.).
|
||||
- If you must ask: do all non-blocked work first, then ask exactly one targeted question, include your recommended default, and state what would change based on the answer.
|
||||
- Never ask permission questions like "Should I proceed?" or "Do you want me to run tests?"; proceed with the most reasonable option and mention what you did.
|
||||
- For substantial work, summarize clearly; follow final‑answer formatting.
|
||||
- Skip heavy formatting for simple confirmations.
|
||||
- Don't dump large files you've written; reference paths only.
|
||||
|
||||
@@ -13,8 +13,7 @@ import PROMPT_BEAST from "./prompt/beast.txt"
|
||||
import PROMPT_GEMINI from "./prompt/gemini.txt"
|
||||
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
|
||||
|
||||
import PROMPT_CODEX from "./prompt/codex.txt"
|
||||
import PROMPT_CODEX_INSTRUCTIONS from "./prompt/codex_header.txt"
|
||||
import PROMPT_CODEX from "./prompt/codex_header.txt"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
import { Flag } from "@/flag/flag"
|
||||
|
||||
@@ -25,7 +24,7 @@ export namespace SystemPrompt {
|
||||
}
|
||||
|
||||
export function instructions() {
|
||||
return PROMPT_CODEX_INSTRUCTIONS.trim()
|
||||
return PROMPT_CODEX.trim()
|
||||
}
|
||||
|
||||
export function provider(model: Provider.Model) {
|
||||
|
||||
@@ -2,7 +2,6 @@ import z from "zod"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { Tool } from "./tool"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Bus } from "../bus"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
import { Instance } from "../project/instance"
|
||||
@@ -96,8 +95,6 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
|
||||
throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`)
|
||||
}
|
||||
|
||||
// Read file and update time tracking (like edit tool does)
|
||||
await FileTime.assert(ctx.sessionID, filePath)
|
||||
const oldContent = await fs.readFile(filePath, "utf-8")
|
||||
let newContent = oldContent
|
||||
|
||||
@@ -202,12 +199,6 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
|
||||
changedFiles.push(change.filePath)
|
||||
break
|
||||
}
|
||||
|
||||
// Update file time tracking
|
||||
FileTime.read(ctx.sessionID, change.filePath)
|
||||
if (change.movePath) {
|
||||
FileTime.read(ctx.sessionID, change.movePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Publish file change events
|
||||
|
||||
@@ -6,7 +6,7 @@ Payload Format (JSON array):
|
||||
[{"tool": "read", "parameters": {"filePath": "src/index.ts", "limit": 350}},{"tool": "grep", "parameters": {"pattern": "Session\\.updatePart", "include": "src/**/*.ts"}},{"tool": "bash", "parameters": {"command": "git status", "description": "Shows working tree status"}}]
|
||||
|
||||
Notes:
|
||||
- 1–20 tool calls per batch
|
||||
- 1–25 tool calls per batch
|
||||
- All calls start in parallel; ordering NOT guaranteed
|
||||
- Partial failures do not stop other tool calls
|
||||
- Do NOT use the batch tool within another batch tool.
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { ApplyPatchTool } from "../../src/tool/apply_patch"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { FileTime } from "../../src/file/time"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const baseCtx = {
|
||||
@@ -71,8 +70,6 @@ describe("tool.apply_patch freeform", () => {
|
||||
const deletePath = path.join(fixture.path, "delete.txt")
|
||||
await fs.writeFile(modifyPath, "line1\nline2\n", "utf-8")
|
||||
await fs.writeFile(deletePath, "obsolete\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, modifyPath)
|
||||
FileTime.read(ctx.sessionID, deletePath)
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Delete File: delete.txt\n*** Update File: modify.txt\n@@\n-line2\n+changed\n*** End Patch"
|
||||
@@ -101,7 +98,6 @@ describe("tool.apply_patch freeform", () => {
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "multi.txt")
|
||||
await fs.writeFile(target, "line1\nline2\nline3\nline4\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch"
|
||||
@@ -122,7 +118,6 @@ describe("tool.apply_patch freeform", () => {
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "insert_only.txt")
|
||||
await fs.writeFile(target, "alpha\nomega\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"
|
||||
|
||||
@@ -142,7 +137,6 @@ describe("tool.apply_patch freeform", () => {
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "no_newline.txt")
|
||||
await fs.writeFile(target, "no newline at end", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch"
|
||||
@@ -166,7 +160,6 @@ describe("tool.apply_patch freeform", () => {
|
||||
const original = path.join(fixture.path, "old", "name.txt")
|
||||
await fs.mkdir(path.dirname(original), { recursive: true })
|
||||
await fs.writeFile(original, "old content\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, original)
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch"
|
||||
@@ -193,7 +186,6 @@ describe("tool.apply_patch freeform", () => {
|
||||
await fs.mkdir(path.dirname(destination), { recursive: true })
|
||||
await fs.writeFile(original, "from\n", "utf-8")
|
||||
await fs.writeFile(destination, "existing\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, original)
|
||||
|
||||
const patchText =
|
||||
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch"
|
||||
@@ -294,7 +286,6 @@ describe("tool.apply_patch freeform", () => {
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "modify.txt")
|
||||
await fs.writeFile(target, "line1\nline2\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch"
|
||||
|
||||
@@ -331,7 +322,6 @@ describe("tool.apply_patch freeform", () => {
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "tail.txt")
|
||||
await fs.writeFile(target, "alpha\nlast\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch"
|
||||
|
||||
@@ -350,7 +340,6 @@ describe("tool.apply_patch freeform", () => {
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "two_chunks.txt")
|
||||
await fs.writeFile(target, "a\nb\nc\nd\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"
|
||||
|
||||
@@ -369,7 +358,6 @@ describe("tool.apply_patch freeform", () => {
|
||||
fn: async () => {
|
||||
const target = path.join(fixture.path, "multi_ctx.txt")
|
||||
await fs.writeFile(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
const patchText = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"
|
||||
|
||||
@@ -389,7 +377,6 @@ describe("tool.apply_patch freeform", () => {
|
||||
const target = path.join(fixture.path, "eof_anchor.txt")
|
||||
// File has duplicate "marker" lines - one in middle, one at end
|
||||
await fs.writeFile(target, "start\nmarker\nmiddle\nmarker\nend\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
// With EOF anchor, should match the LAST "marker" line, not the first
|
||||
const patchText =
|
||||
@@ -454,7 +441,6 @@ EOF`
|
||||
const target = path.join(fixture.path, "trailing_ws.txt")
|
||||
// File has trailing spaces on some lines
|
||||
await fs.writeFile(target, "line1 \nline2\nline3 \n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
// Patch doesn't have trailing spaces - should still match via rstrip pass
|
||||
const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
|
||||
@@ -475,7 +461,6 @@ EOF`
|
||||
const target = path.join(fixture.path, "leading_ws.txt")
|
||||
// File has leading spaces
|
||||
await fs.writeFile(target, " line1\nline2\n line3\n", "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
// Patch without leading spaces - should match via trim pass
|
||||
const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
|
||||
@@ -499,7 +484,6 @@ EOF`
|
||||
const rightQuote = "\u201D"
|
||||
const emDash = "\u2014"
|
||||
await fs.writeFile(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`, "utf-8")
|
||||
FileTime.read(ctx.sessionID, target)
|
||||
|
||||
// Patch uses ASCII equivalents - should match via normalized pass
|
||||
// The replacement uses ASCII quotes from the patch (not preserving Unicode)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"name": "@opencode-ai/script",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/bun": "catalog:"
|
||||
"@types/bun": "catalog:",
|
||||
"@types/semver": "catalog:"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import { satisfies } from "semver"
|
||||
|
||||
const rootPkgPath = path.resolve(import.meta.dir, "../../../package.json")
|
||||
const rootPkg = await Bun.file(rootPkgPath).json()
|
||||
@@ -9,8 +10,11 @@ if (!expectedBunVersion) {
|
||||
throw new Error("packageManager field not found in root package.json")
|
||||
}
|
||||
|
||||
if (process.versions.bun !== expectedBunVersion) {
|
||||
throw new Error(`This script requires bun@${expectedBunVersion}, but you are using bun@${process.versions.bun}`)
|
||||
// relax version requirement
|
||||
const expectedBunVersionRange = `^${expectedBunVersion}`
|
||||
|
||||
if (!satisfies(process.versions.bun, expectedBunVersionRange)) {
|
||||
throw new Error(`This script requires bun@${expectedBunVersionRange}, but you are using bun@${process.versions.bun}`)
|
||||
}
|
||||
|
||||
const env = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -48,6 +48,20 @@ export type EventServerInstanceDisposed = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventServerConnected = {
|
||||
type: "server.connected"
|
||||
properties: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type EventGlobalDisposed = {
|
||||
type: "global.disposed"
|
||||
properties: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type EventLspClientDiagnostics = {
|
||||
type: "lsp.client.diagnostics"
|
||||
properties: {
|
||||
@@ -846,25 +860,13 @@ export type EventPtyDeleted = {
|
||||
}
|
||||
}
|
||||
|
||||
export type EventGlobalDisposed = {
|
||||
type: "global.disposed"
|
||||
properties: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type EventServerConnected = {
|
||||
type: "server.connected"
|
||||
properties: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type Event =
|
||||
| EventInstallationUpdated
|
||||
| EventInstallationUpdateAvailable
|
||||
| EventProjectUpdated
|
||||
| EventServerInstanceDisposed
|
||||
| EventServerConnected
|
||||
| EventGlobalDisposed
|
||||
| EventLspClientDiagnostics
|
||||
| EventLspUpdated
|
||||
| EventFileEdited
|
||||
@@ -899,8 +901,6 @@ export type Event =
|
||||
| EventPtyUpdated
|
||||
| EventPtyExited
|
||||
| EventPtyDeleted
|
||||
| EventGlobalDisposed
|
||||
| EventServerConnected
|
||||
|
||||
export type GlobalEvent = {
|
||||
directory: string
|
||||
|
||||
+34
-34
@@ -5863,6 +5863,34 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.server.connected": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "server.connected"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.global.disposed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "global.disposed"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.lsp.client.diagnostics": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -8075,34 +8103,6 @@
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.global.disposed": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "global.disposed"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event.server.connected": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"const": "server.connected"
|
||||
},
|
||||
"properties": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
"required": ["type", "properties"]
|
||||
},
|
||||
"Event": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -8117,6 +8117,12 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.server.instance.disposed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.server.connected"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.global.disposed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.lsp.client.diagnostics"
|
||||
},
|
||||
@@ -8218,12 +8224,6 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.pty.deleted"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.global.disposed"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Event.server.connected"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0px;
|
||||
gap: 8px;
|
||||
align-self: stretch;
|
||||
|
||||
[data-slot="accordion-item"] {
|
||||
@@ -10,7 +10,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0px;
|
||||
align-self: stretch;
|
||||
overflow: clip;
|
||||
|
||||
@@ -34,6 +33,7 @@
|
||||
|
||||
background-color: var(--surface-base);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: clip;
|
||||
color: var(--text-strong);
|
||||
transition: background-color 0.15s ease;
|
||||
@@ -59,12 +59,9 @@
|
||||
}
|
||||
|
||||
&[data-expanded] {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
[data-slot="accordion-trigger"] {
|
||||
border-top-left-radius: var(--radius-md);
|
||||
border-top-right-radius: var(--radius-md);
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
[data-slot="accordion-content"] {
|
||||
@@ -73,68 +70,11 @@
|
||||
border-bottom-left-radius: var(--radius-md);
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
& + [data-slot="accordion-item"] {
|
||||
margin-top: 8px;
|
||||
|
||||
[data-slot="accordion-trigger"] {
|
||||
border-top-left-radius: var(--radius-md);
|
||||
border-top-right-radius: var(--radius-md);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:has(+ [data-slot="accordion-item"][data-expanded]) {
|
||||
margin-bottom: 8px;
|
||||
|
||||
&[data-closed] {
|
||||
border-bottom-left-radius: var(--radius-md);
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
|
||||
[data-slot="accordion-trigger"] {
|
||||
border-bottom-left-radius: var(--radius-md);
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-closed] + &[data-closed] {
|
||||
[data-slot="accordion-trigger"] {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0px;
|
||||
|
||||
&[data-closed] {
|
||||
[data-slot="accordion-trigger"] {
|
||||
border-top-left-radius: var(--radius-md);
|
||||
border-top-right-radius: var(--radius-md);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0px;
|
||||
|
||||
&[data-closed] {
|
||||
[data-slot="accordion-trigger"] {
|
||||
border-bottom-left-radius: var(--radius-md);
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="accordion-content"] {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
|
||||
/* animation: slideUp 250ms cubic-bezier(0.87, 0, 0.13, 1); */
|
||||
/**/
|
||||
/* &[data-expanded] { */
|
||||
/* animation: slideDown 250ms cubic-bezier(0.87, 0, 0.13, 1); */
|
||||
/* } */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-items: start;
|
||||
overflow: visible;
|
||||
|
||||
[data-slot="dialog-content"] {
|
||||
display: flex;
|
||||
@@ -39,18 +40,21 @@
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
min-height: 280px;
|
||||
overflow: auto;
|
||||
|
||||
/* Hide scrollbar */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* padding: 8px; */
|
||||
/* padding: 8px 8px 0 8px; */
|
||||
border: 1px solid
|
||||
light-dark(
|
||||
color-mix(in oklch, var(--border-base) 30%, transparent),
|
||||
color-mix(in oklch, var(--border-base) 50%, transparent)
|
||||
);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--surface-raised-stronger-non-alpha);
|
||||
background-clip: padding-box;
|
||||
box-shadow: var(--shadow-lg);
|
||||
box-shadow: var(--shadow-lg-border-base);
|
||||
|
||||
/* animation: contentHide 300ms ease-in forwards; */
|
||||
/**/
|
||||
@@ -108,7 +112,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow: hidden;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
@@ -129,6 +133,16 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-size="large"] [data-slot="dialog-container"] {
|
||||
width: min(calc(100vw - 32px), 800px);
|
||||
height: min(calc(100vh - 32px), 600px);
|
||||
}
|
||||
|
||||
&[data-size="x-large"] [data-slot="dialog-container"] {
|
||||
width: min(calc(100vw - 32px), 960px);
|
||||
height: min(calc(100vh - 32px), 600px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes overlayShow {
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface DialogProps extends ParentProps {
|
||||
title?: JSXElement
|
||||
description?: JSXElement
|
||||
action?: JSXElement
|
||||
size?: "normal" | "large" | "x-large"
|
||||
class?: ComponentProps<"div">["class"]
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
fit?: boolean
|
||||
@@ -13,10 +14,11 @@ export interface DialogProps extends ParentProps {
|
||||
|
||||
export function Dialog(props: DialogProps) {
|
||||
return (
|
||||
<div data-component="dialog" data-fit={props.fit ? true : undefined}>
|
||||
<div data-component="dialog" data-fit={props.fit ? true : undefined} data-size={props.size || "normal"}>
|
||||
<div data-slot="dialog-container">
|
||||
<Kobalte.Content
|
||||
data-slot="dialog-content"
|
||||
data-no-header={!props.title && !props.action ? "" : undefined}
|
||||
classList={{
|
||||
...(props.classList ?? {}),
|
||||
[props.class ?? ""]: !!props.class,
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
z-index: 50;
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: 8px;
|
||||
background-color: var(--surface-raised-stronger-non-alpha);
|
||||
pointer-events: auto;
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
}
|
||||
|
||||
[data-slot="hover-card-body"] {
|
||||
padding: 12px;
|
||||
padding: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -125,11 +125,6 @@
|
||||
/* padding: 0 8px 0 6px; */
|
||||
gap: 8px;
|
||||
|
||||
[data-slot="icon-svg"] {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
|
||||
/* text-12-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
|
||||
@@ -66,6 +66,9 @@ const icons = {
|
||||
dash: `<rect x="5" y="9.5" width="10" height="1" fill="currentColor"/>`,
|
||||
"cloud-upload": `<path d="M12.0833 16.25H15C17.0711 16.25 18.75 14.5711 18.75 12.5C18.75 10.5649 17.2843 8.97217 15.4025 8.77133C15.2 6.13103 12.8586 4.08333 10 4.08333C7.71532 4.08333 5.76101 5.49781 4.96501 7.49881C2.84892 7.90461 1.25 9.76559 1.25 11.6667C1.25 13.9813 3.30203 16.25 5.83333 16.25H7.91667M10 16.25V10.4167M12.0833 11.875L10 9.79167L7.91667 11.875" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
trash: `<path d="M4.58342 17.9134L4.58369 17.4134L4.22787 17.5384L4.22766 18.0384H4.58342V17.9134ZM15.4167 17.9134V18.0384H15.7725L15.7723 17.5384L15.4167 17.9134ZM2.08342 3.95508V3.45508H1.58342V3.95508H2.08342V4.45508V3.95508ZM17.9167 4.45508V4.95508H18.4167V4.45508H17.9167V3.95508V4.45508ZM4.16677 4.58008L3.66701 4.5996L4.22816 17.5379L4.72792 17.4934L5.22767 17.4489L4.66652 4.54055L4.16677 4.58008ZM4.58342 18.0384V17.9134H15.4167V18.0384V18.5384H4.58342V18.0384ZM15.4167 17.9134L15.8332 17.5379L16.2498 4.5996L15.7501 4.58008L15.2503 4.56055L14.8337 17.4989L15.4167 17.9134ZM15.8334 4.58008V4.08008H4.16677V4.58008V5.08008H15.8334V4.58008ZM2.08342 4.45508V4.95508H4.16677V4.58008V4.08008H2.08342V4.45508ZM15.8334 4.58008V5.08008H17.9167V4.45508V3.95508H15.8334V4.58008ZM6.83951 4.35149L7.432 4.55047C7.79251 3.47701 8.80699 2.70508 10.0001 2.70508V2.20508V1.70508C8.25392 1.70508 6.77335 2.83539 6.24702 4.15251L6.83951 4.35149ZM10.0001 2.20508V2.70508C11.1932 2.70508 12.2077 3.47701 12.5682 4.55047L13.1607 4.35149L13.7532 4.15251C13.2269 2.83539 11.7463 1.70508 10.0001 1.70508V2.20508Z" fill="currentColor"/>`,
|
||||
sliders: `<path d="M3.625 6.25H10.9375M16.375 13.75H10.5625M3.625 13.75H4.9375M11.125 6.25C11.125 4.79969 12.2997 3.625 13.75 3.625C15.2003 3.625 16.375 4.79969 16.375 6.25C16.375 7.70031 15.2003 8.875 13.75 8.875C12.2997 8.875 11.125 7.70031 11.125 6.25ZM10.375 13.75C10.375 15.2003 9.20031 16.375 7.75 16.375C6.29969 16.375 5.125 15.2003 5.125 13.75C5.125 12.2997 6.29969 11.125 7.75 11.125C9.20031 11.125 10.375 12.2997 10.375 13.75Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
keyboard: `<path d="M5.125 7.375V4.375H14.875V2.875M8.3125 13.9375H11.6875M8.125 13.9375H11.875M2.125 7.375H17.875V17.125H2.125V7.375ZM5.5 10.375H5.125V10.75H5.5V10.375ZM8.5 10.375H8.125V10.75H8.5V10.375ZM11.875 10.375H11.5V10.75H11.875V10.375ZM14.875 10.375H14.5V10.75H14.875V10.375ZM14.875 13.75H14.5V14.125H14.875V13.75ZM5.5 13.75H5.125V14.125H5.5V13.75Z" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
selector: `<path d="M6.66626 12.5033L9.99959 15.8366L13.3329 12.5033M6.66626 7.50326L9.99959 4.16992L13.3329 7.50326" stroke="currentColor" stroke-linecap="square"/>`,
|
||||
}
|
||||
|
||||
export interface IconProps extends ComponentProps<"svg"> {
|
||||
|
||||
@@ -133,7 +133,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
|
||||
const index = selected ? all.indexOf(selected) : -1
|
||||
props.onKeyEvent?.(e, selected)
|
||||
|
||||
if (e.key === "Enter") {
|
||||
if (e.key === "Enter" && !e.isComposing) {
|
||||
e.preventDefault()
|
||||
if (selected) handleSelect(selected, index)
|
||||
} else {
|
||||
|
||||
@@ -72,7 +72,7 @@
|
||||
|
||||
[data-slot="message-nav-title-preview"] {
|
||||
font-size: 14px; /* text-14-regular */
|
||||
color: var(--text-weak);
|
||||
color: var(--text-base);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: var(--surface-base);
|
||||
background: var(--surface-weak);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
transition: border-color 0.15s ease;
|
||||
|
||||
@@ -76,7 +76,8 @@
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
background: var(--surface-base);
|
||||
background: var(--surface-weak);
|
||||
border: 1px solid var(--border-weak-base);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -113,11 +114,22 @@
|
||||
|
||||
[data-component="reasoning-part"] {
|
||||
width: 100%;
|
||||
opacity: 0.5;
|
||||
color: var(--text-base);
|
||||
opacity: 0.8;
|
||||
line-height: var(--line-height-large);
|
||||
|
||||
[data-component="markdown"] {
|
||||
margin-top: 24px;
|
||||
font-style: italic !important;
|
||||
|
||||
p:has(strong) {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 0;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -357,6 +357,11 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const toggleExpanded = () => {
|
||||
if (!canExpand()) return
|
||||
setExpanded((value) => !value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-component="user-message" data-expanded={expanded()} data-can-expand={canExpand()}>
|
||||
<Show when={attachments().length > 0}>
|
||||
@@ -388,19 +393,29 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={text()}>
|
||||
<div data-slot="user-message-text" ref={(el) => (textRef = el)}>
|
||||
<div data-slot="user-message-text" ref={(el) => (textRef = el)} onClick={toggleExpanded}>
|
||||
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
|
||||
<button
|
||||
data-slot="user-message-expand"
|
||||
type="button"
|
||||
aria-label={expanded() ? "Collapse message" : "Expand message"}
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
toggleExpanded()
|
||||
}}
|
||||
>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</button>
|
||||
<div data-slot="user-message-copy-wrapper">
|
||||
<Tooltip value={copied() ? "Copied!" : "Copy"} placement="top" gutter={8}>
|
||||
<IconButton icon={copied() ? "check" : "copy"} variant="secondary" onClick={handleCopy} />
|
||||
<IconButton
|
||||
icon={copied() ? "check" : "copy"}
|
||||
variant="secondary"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
handleCopy()
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
@@ -977,6 +992,7 @@ ToolRegistry.register({
|
||||
render(props) {
|
||||
const diffComponent = useDiffComponent()
|
||||
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
|
||||
const filename = () => getFilename(props.input.filePath ?? "")
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
@@ -984,13 +1000,12 @@ ToolRegistry.register({
|
||||
trigger={
|
||||
<div data-component="edit-trigger">
|
||||
<div data-slot="message-part-title-area">
|
||||
<div data-slot="message-part-title">Edit</div>
|
||||
<div data-slot="message-part-path">
|
||||
<Show when={props.input.filePath?.includes("/")}>
|
||||
<div data-slot="message-part-title">Edit {filename()}</div>
|
||||
<Show when={props.input.filePath?.includes("/")}>
|
||||
<div data-slot="message-part-path">
|
||||
<span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span>
|
||||
</Show>
|
||||
<span data-slot="message-part-filename">{getFilename(props.input.filePath ?? "")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div data-slot="message-part-actions">
|
||||
<Show when={props.metadata.filediff}>
|
||||
@@ -1026,6 +1041,7 @@ ToolRegistry.register({
|
||||
render(props) {
|
||||
const codeComponent = useCodeComponent()
|
||||
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
|
||||
const filename = () => getFilename(props.input.filePath ?? "")
|
||||
return (
|
||||
<BasicTool
|
||||
{...props}
|
||||
@@ -1033,13 +1049,12 @@ ToolRegistry.register({
|
||||
trigger={
|
||||
<div data-component="write-trigger">
|
||||
<div data-slot="message-part-title-area">
|
||||
<div data-slot="message-part-title">Write</div>
|
||||
<div data-slot="message-part-path">
|
||||
<Show when={props.input.filePath?.includes("/")}>
|
||||
<div data-slot="message-part-title">Write {filename()}</div>
|
||||
<Show when={props.input.filePath?.includes("/")}>
|
||||
<div data-slot="message-part-path">
|
||||
<span data-slot="message-part-directory">{getDirectory(props.input.filePath!)}</span>
|
||||
</Show>
|
||||
<span data-slot="message-part-filename">{getFilename(props.input.filePath ?? "")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<div data-slot="message-part-actions">{/* <DiffChanges diff={diff} /> */}</div>
|
||||
</div>
|
||||
|
||||
@@ -43,21 +43,62 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-trigger-style="settings"] {
|
||||
[data-slot="select-select-trigger"] {
|
||||
padding: 6px 6px 6px 12px;
|
||||
box-shadow: none;
|
||||
border-radius: 6px;
|
||||
min-width: 160px;
|
||||
height: 32px;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
background-color: transparent;
|
||||
|
||||
[data-slot="select-select-trigger-value"] {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-regular);
|
||||
}
|
||||
[data-slot="select-select-trigger-icon"] {
|
||||
width: 16px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-weak);
|
||||
background-color: var(--surface-raised-base);
|
||||
border-radius: 4px;
|
||||
transition: transform 0.1s ease-in-out;
|
||||
}
|
||||
|
||||
&[data-slot="select-select-trigger"]:hover:not(:disabled),
|
||||
&[data-slot="select-select-trigger"][data-expanded],
|
||||
&[data-slot="select-select-trigger"][data-expanded]:hover:not(:disabled) {
|
||||
background-color: var(--input-base);
|
||||
box-shadow: var(--shadow-xs-border-base);
|
||||
}
|
||||
|
||||
&:not([data-expanded]):focus {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="select-content"] {
|
||||
min-width: 4rem;
|
||||
min-width: 104px;
|
||||
max-width: 23rem;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--surface-raised-stronger-non-alpha);
|
||||
padding: 2px;
|
||||
padding: 4px;
|
||||
box-shadow: var(--shadow-xs-border);
|
||||
z-index: 50;
|
||||
|
||||
&[data-closed] {
|
||||
animation: select-close 0.15s ease-out;
|
||||
}
|
||||
z-index: 60;
|
||||
|
||||
&[data-expanded] {
|
||||
animation: select-open 0.15s ease-out;
|
||||
@@ -80,16 +121,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* [data-slot="select-section"] { */
|
||||
/* } */
|
||||
|
||||
[data-slot="select-select-item"] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 6px 0 6px;
|
||||
padding: 2px 8px;
|
||||
gap: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: 4px;
|
||||
cursor: default;
|
||||
|
||||
/* text-12-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
@@ -131,6 +170,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="select-content"][data-trigger-style="settings"] {
|
||||
min-width: 160px;
|
||||
border-radius: 8px;
|
||||
padding: 0;
|
||||
|
||||
[data-slot="select-select-content-list"] {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
[data-slot="select-select-item"] {
|
||||
/* text-14-regular */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
font-style: normal;
|
||||
font-weight: var(--font-weight-regular);
|
||||
line-height: var(--line-height-large);
|
||||
letter-spacing: var(--letter-spacing-normal);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes select-open {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -141,14 +200,3 @@
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes select-close {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Select as Kobalte } from "@kobalte/core/select"
|
||||
import { createMemo, splitProps, type ComponentProps, type JSX } from "solid-js"
|
||||
import { createMemo, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js"
|
||||
import { pipe, groupBy, entries, map } from "remeda"
|
||||
import { Button, ButtonProps } from "./button"
|
||||
import { Icon } from "./icon"
|
||||
@@ -12,12 +12,15 @@ export type SelectProps<T> = Omit<ComponentProps<typeof Kobalte<T>>, "value" | "
|
||||
label?: (x: T) => string
|
||||
groupBy?: (x: T) => string
|
||||
onSelect?: (value: T | undefined) => void
|
||||
onHighlight?: (value: T | undefined) => (() => void) | void
|
||||
class?: ComponentProps<"div">["class"]
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
children?: (item: T | undefined) => JSX.Element
|
||||
triggerStyle?: JSX.CSSProperties
|
||||
triggerVariant?: "settings"
|
||||
}
|
||||
|
||||
export function Select<T>(props: SelectProps<T> & ButtonProps) {
|
||||
export function Select<T>(props: SelectProps<T> & Omit<ButtonProps, "children">) {
|
||||
const [local, others] = splitProps(props, [
|
||||
"class",
|
||||
"classList",
|
||||
@@ -28,8 +31,42 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
|
||||
"label",
|
||||
"groupBy",
|
||||
"onSelect",
|
||||
"onHighlight",
|
||||
"onOpenChange",
|
||||
"children",
|
||||
"triggerStyle",
|
||||
"triggerVariant",
|
||||
])
|
||||
|
||||
const state = {
|
||||
key: undefined as string | undefined,
|
||||
cleanup: undefined as (() => void) | void,
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
state.cleanup?.()
|
||||
state.cleanup = undefined
|
||||
state.key = undefined
|
||||
}
|
||||
|
||||
const keyFor = (item: T) => (local.value ? local.value(item) : (item as string))
|
||||
|
||||
const move = (item: T | undefined) => {
|
||||
if (!local.onHighlight) return
|
||||
if (!item) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
|
||||
const key = keyFor(item)
|
||||
if (state.key === key) return
|
||||
state.cleanup?.()
|
||||
state.cleanup = local.onHighlight(item)
|
||||
state.key = key
|
||||
}
|
||||
|
||||
onCleanup(stop)
|
||||
|
||||
const grouped = createMemo(() => {
|
||||
const result = pipe(
|
||||
local.options,
|
||||
@@ -46,7 +83,9 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
|
||||
<Kobalte<T, { category: string; options: T[] }>
|
||||
{...others}
|
||||
data-component="select"
|
||||
placement="bottom-start"
|
||||
data-trigger-style={local.triggerVariant}
|
||||
placement={local.triggerVariant === "settings" ? "bottom-end" : "bottom-start"}
|
||||
gutter={4}
|
||||
value={local.current}
|
||||
options={grouped()}
|
||||
optionValue={(x) => (local.value ? local.value(x) : (x as string))}
|
||||
@@ -58,12 +97,14 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
|
||||
)}
|
||||
itemComponent={(itemProps) => (
|
||||
<Kobalte.Item
|
||||
{...itemProps}
|
||||
data-slot="select-select-item"
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
{...itemProps}
|
||||
onPointerEnter={() => move(itemProps.item.rawValue)}
|
||||
onPointerMove={() => move(itemProps.item.rawValue)}
|
||||
>
|
||||
<Kobalte.ItemLabel data-slot="select-select-item-label">
|
||||
{local.children
|
||||
@@ -79,6 +120,11 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
|
||||
)}
|
||||
onChange={(v) => {
|
||||
local.onSelect?.(v ?? undefined)
|
||||
stop()
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
local.onOpenChange?.(open)
|
||||
if (!open) stop()
|
||||
}}
|
||||
>
|
||||
<Kobalte.Trigger
|
||||
@@ -87,6 +133,7 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
|
||||
as={Button}
|
||||
size={props.size}
|
||||
variant={props.variant}
|
||||
style={local.triggerStyle}
|
||||
classList={{
|
||||
...(local.classList ?? {}),
|
||||
[local.class ?? ""]: !!local.class,
|
||||
@@ -101,7 +148,7 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
|
||||
}}
|
||||
</Kobalte.Value>
|
||||
<Kobalte.Icon data-slot="select-select-trigger-icon">
|
||||
<Icon name="chevron-down" size="small" />
|
||||
<Icon name={local.triggerVariant === "settings" ? "selector" : "chevron-down"} size="small" />
|
||||
</Kobalte.Icon>
|
||||
</Kobalte.Trigger>
|
||||
<Kobalte.Portal>
|
||||
@@ -111,6 +158,7 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
|
||||
[local.class ?? ""]: !!local.class,
|
||||
}}
|
||||
data-component="select-content"
|
||||
data-trigger-style={local.triggerVariant}
|
||||
>
|
||||
<Kobalte.Listbox data-slot="select-select-content-list" />
|
||||
</Kobalte.Content>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
type PermissionRequest,
|
||||
TextPart,
|
||||
ToolPart,
|
||||
UserMessage,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { useData } from "../context"
|
||||
import { useDiffComponent } from "../context/diff"
|
||||
@@ -21,8 +20,6 @@ import { Accordion } from "./accordion"
|
||||
import { StickyAccordionHeader } from "./sticky-accordion-header"
|
||||
import { FileIcon } from "./file-icon"
|
||||
import { Icon } from "./icon"
|
||||
import { IconButton } from "./icon-button"
|
||||
import { Tooltip } from "./tooltip"
|
||||
import { Card } from "./card"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { Button } from "./button"
|
||||
@@ -352,7 +349,6 @@ export function SessionTurn(
|
||||
const hasDiffs = createMemo(() => (data.store.session_diff?.[props.sessionID]?.length ?? 0) > 0)
|
||||
const hideResponsePart = createMemo(() => !working() && !!responsePartId())
|
||||
|
||||
const [responseCopied, setResponseCopied] = createSignal(false)
|
||||
const [rootRef, setRootRef] = createSignal<HTMLDivElement | undefined>()
|
||||
const [stickyRef, setStickyRef] = createSignal<HTMLDivElement | undefined>()
|
||||
|
||||
@@ -362,13 +358,6 @@ export function SessionTurn(
|
||||
const next = Math.ceil(height)
|
||||
root.style.setProperty("--session-turn-sticky-height", `${next}px`)
|
||||
}
|
||||
const handleCopyResponse = async () => {
|
||||
const content = response()
|
||||
if (!content) return
|
||||
await navigator.clipboard.writeText(content)
|
||||
setResponseCopied(true)
|
||||
setTimeout(() => setResponseCopied(false), 2000)
|
||||
}
|
||||
|
||||
function duration() {
|
||||
const msg = message()
|
||||
@@ -589,15 +578,6 @@ export function SessionTurn(
|
||||
{/* Response */}
|
||||
<Show when={!working() && (response() || hasDiffs())}>
|
||||
<div data-slot="session-turn-summary-section">
|
||||
<div data-slot="session-turn-summary-copy">
|
||||
<Tooltip value={responseCopied() ? "Copied!" : "Copy"} placement="top" gutter={8}>
|
||||
<IconButton
|
||||
icon={responseCopied() ? "check" : "copy"}
|
||||
variant="secondary"
|
||||
onClick={handleCopyResponse}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div data-slot="session-turn-summary-header">
|
||||
<h2 data-slot="session-turn-summary-title">Response</h2>
|
||||
<Markdown
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
[data-component="switch"] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
@@ -215,24 +215,36 @@
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
gap: 4px;
|
||||
background-color: var(--background-base);
|
||||
border-right: 1px solid var(--border-weak-base);
|
||||
|
||||
&::after {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
flex-grow: 1;
|
||||
border-bottom: none;
|
||||
border-right: 1px solid var(--border-weak-base);
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="tabs-trigger-wrapper"] {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border-bottom: none;
|
||||
border-right: 1px solid var(--border-weak-base);
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background-color: transparent;
|
||||
|
||||
[data-slot="tabs-trigger"] {
|
||||
padding: 0 8px;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--surface-raised-base-hover);
|
||||
}
|
||||
|
||||
&:has([data-selected]) {
|
||||
border-right-color: transparent;
|
||||
background-color: var(--surface-raised-base-active);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,32 +255,105 @@
|
||||
|
||||
&[data-variant="alt"] {
|
||||
[data-slot="tabs-list"] {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
padding-top: 24px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: none;
|
||||
border-right: 1px solid var(--border-weak-base);
|
||||
padding: 8px;
|
||||
gap: 4px;
|
||||
border: none;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="tabs-trigger-wrapper"] {
|
||||
border-bottom: none;
|
||||
border-right-width: 2px;
|
||||
border-right-style: solid;
|
||||
border-right-color: transparent;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
|
||||
[data-slot="tabs-trigger"] {
|
||||
border-bottom: none;
|
||||
border: none;
|
||||
padding: 0 8px;
|
||||
gap: 8px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--surface-raised-base-hover);
|
||||
}
|
||||
|
||||
&:has([data-selected]) {
|
||||
border-right-color: var(--icon-strong-base);
|
||||
background-color: var(--surface-raised-base-hover);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-variant="settings"] {
|
||||
[data-slot="tabs-list"] {
|
||||
width: 200px;
|
||||
min-width: 200px;
|
||||
padding: 12px;
|
||||
gap: 0;
|
||||
background-color: var(--background-base);
|
||||
border-right: 1px solid var(--border-weak-base);
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="tabs-section-title"] {
|
||||
width: 100%;
|
||||
padding: 0 0 0 4px;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--text-weak);
|
||||
}
|
||||
|
||||
[data-slot="tabs-trigger-wrapper"] {
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
/* text-14-medium */
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-medium);
|
||||
line-height: var(--line-height-large);
|
||||
|
||||
[data-slot="tabs-trigger"] {
|
||||
border: none;
|
||||
padding: 0 8px;
|
||||
gap: 12px;
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
[data-component="icon"] {
|
||||
color: var(--icon-base);
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--surface-raised-base-hover);
|
||||
}
|
||||
|
||||
&:has([data-selected]) {
|
||||
background-color: var(--surface-raised-base-active);
|
||||
color: var(--text-strong);
|
||||
|
||||
[data-component="icon"] {
|
||||
color: var(--icon-strong-base);
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--surface-raised-base-active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="tabs-content"] {
|
||||
background-color: var(--surface-raised-stronger-non-alpha);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Tabs as Kobalte } from "@kobalte/core/tabs"
|
||||
import { Show, splitProps, type JSX } from "solid-js"
|
||||
import type { ComponentProps, ParentProps } from "solid-js"
|
||||
import type { ComponentProps, ParentProps, Component } from "solid-js"
|
||||
|
||||
export interface TabsProps extends ComponentProps<typeof Kobalte> {
|
||||
variant?: "normal" | "alt"
|
||||
variant?: "normal" | "alt" | "settings"
|
||||
orientation?: "horizontal" | "vertical"
|
||||
}
|
||||
export interface TabsListProps extends ComponentProps<typeof Kobalte.List> {}
|
||||
@@ -106,8 +106,13 @@ function TabsContent(props: ParentProps<TabsContentProps>) {
|
||||
)
|
||||
}
|
||||
|
||||
const TabsSectionTitle: Component<ParentProps> = (props) => {
|
||||
return <div data-slot="tabs-section-title">{props.children}</div>
|
||||
}
|
||||
|
||||
export const Tabs = Object.assign(TabsRoot, {
|
||||
List: TabsList,
|
||||
Trigger: TabsTrigger,
|
||||
Content: TabsContent,
|
||||
SectionTitle: TabsSectionTitle,
|
||||
})
|
||||
|
||||
@@ -77,7 +77,7 @@ export function useFilteredList<T>(props: FilteredListProps<T>) {
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Enter") {
|
||||
if (event.key === "Enter" && !event.isComposing) {
|
||||
event.preventDefault()
|
||||
const selectedIndex = flat().findIndex((x) => props.key(x) === list.active())
|
||||
const selected = flat()[selectedIndex]
|
||||
|
||||
@@ -73,6 +73,10 @@
|
||||
0 0 0 1px var(--border-base, rgba(11, 6, 0, 0.2)), 0 1px 2px -1px rgba(19, 16, 16, 0.25),
|
||||
0 1px 2px 0 rgba(19, 16, 16, 0.08), 0 1px 3px 0 rgba(19, 16, 16, 0.12), 0 0 0 2px var(--background-weak, #f1f0f0),
|
||||
0 0 0 3px var(--border-selected, rgba(0, 74, 255, 0.99));
|
||||
--shadow-lg-border-base:
|
||||
0 0 0 1px var(--border-weak-base, rgba(0, 0, 0, 0.07)), 0 36px 80px 0 rgba(0, 0, 0, 0.03),
|
||||
0 13.141px 29.201px 0 rgba(0, 0, 0, 0.04), 0 6.38px 14.177px 0 rgba(0, 0, 0, 0.05),
|
||||
0 3.127px 6.95px 0 rgba(0, 0, 0, 0.06), 0 1.237px 2.748px 0 rgba(0, 0, 0, 0.09);
|
||||
|
||||
color-scheme: light;
|
||||
--text-mix-blend-mode: multiply;
|
||||
|
||||
@@ -5,20 +5,21 @@
|
||||
"light": {
|
||||
"seeds": {
|
||||
"neutral": "#fdfaf4",
|
||||
"primary": "#55b4d4",
|
||||
"success": "#6ac782",
|
||||
"warning": "#f2ae49",
|
||||
"error": "#f05f65",
|
||||
"info": "#36a3d9",
|
||||
"interactive": "#55b4d4",
|
||||
"diffAdd": "#b8df8a",
|
||||
"diffDelete": "#f05f65"
|
||||
"primary": "#4aa8c8",
|
||||
"success": "#5fb978",
|
||||
"warning": "#ea9f41",
|
||||
"error": "#e6656a",
|
||||
"info": "#2f9bce",
|
||||
"interactive": "#4aa8c8",
|
||||
"diffAdd": "#b1d780",
|
||||
"diffDelete": "#e6656a"
|
||||
},
|
||||
"overrides": {
|
||||
"background-base": "#fdfaf4",
|
||||
"background-weak": "#f6f0e7",
|
||||
"background-strong": "#f1ebe2",
|
||||
"background-stronger": "#ece4da",
|
||||
"background-weak": "#fcf9f3",
|
||||
"background-strong": "#fbf8f2",
|
||||
"background-stronger": "#faf7f1",
|
||||
"surface-raised-base-hover": "#f4f0e9",
|
||||
"border-weak-base": "#e6ddcf",
|
||||
"border-weak-hover": "#dcd3c5",
|
||||
"border-weak-active": "#d1c9ba",
|
||||
@@ -31,101 +32,102 @@
|
||||
"border-selected": "#9e9383",
|
||||
"border-disabled": "#efe5d8",
|
||||
"border-focus": "#b09f8f",
|
||||
"border-strong-base": "#8f806f",
|
||||
"border-strong-hover": "#837465",
|
||||
"border-strong-active": "#77685a",
|
||||
"border-strong-selected": "#6b5d51",
|
||||
"border-strong-base": "#837765",
|
||||
"border-strong-hover": "#7a6f5f",
|
||||
"border-strong-active": "#716655",
|
||||
"border-strong-selected": "#685e4e",
|
||||
"border-strong-disabled": "#d8cabc",
|
||||
"border-strong-focus": "#7c6d5e",
|
||||
"border-strong-focus": "#766b5c",
|
||||
"surface-diff-add-base": "#eef5e4",
|
||||
"surface-diff-delete-base": "#fde5e5",
|
||||
"surface-diff-hidden-base": "#e3edf3",
|
||||
"text-base": "#5c6773",
|
||||
"text-weak": "#8a939f",
|
||||
"text-strong": "#2a3038",
|
||||
"syntax-string": "#86b300",
|
||||
"syntax-primitive": "#f28779",
|
||||
"syntax-property": "#55b4d4",
|
||||
"syntax-type": "#f29e32",
|
||||
"syntax-constant": "#36a3d9",
|
||||
"syntax-info": "#36a3d9",
|
||||
"markdown-heading": "#55b4d4",
|
||||
"markdown-text": "#5c6773",
|
||||
"markdown-link": "#55b4d4",
|
||||
"markdown-link-text": "#36a3d9",
|
||||
"markdown-code": "#86b300",
|
||||
"markdown-block-quote": "#f29e32",
|
||||
"markdown-emph": "#f29e32",
|
||||
"markdown-strong": "#f28779",
|
||||
"text-base": "#4f5964",
|
||||
"text-weak": "#77818d",
|
||||
"text-strong": "#1b232b",
|
||||
"syntax-string": "#7fad00",
|
||||
"syntax-primitive": "#ef7d71",
|
||||
"syntax-property": "#4aa8c8",
|
||||
"syntax-type": "#ed982e",
|
||||
"syntax-constant": "#2f9bce",
|
||||
"syntax-info": "#2f9bce",
|
||||
"markdown-heading": "#4aa8c8",
|
||||
"markdown-text": "#4f5964",
|
||||
"markdown-link": "#4aa8c8",
|
||||
"markdown-link-text": "#2f9bce",
|
||||
"markdown-code": "#7fad00",
|
||||
"markdown-block-quote": "#ed982e",
|
||||
"markdown-emph": "#ed982e",
|
||||
"markdown-strong": "#f07f72",
|
||||
"markdown-horizontal-rule": "#d7cec0",
|
||||
"markdown-list-item": "#55b4d4",
|
||||
"markdown-list-enumeration": "#36a3d9",
|
||||
"markdown-image": "#55b4d4",
|
||||
"markdown-image-text": "#36a3d9",
|
||||
"markdown-code-block": "#55b4d4"
|
||||
"markdown-list-item": "#4aa8c8",
|
||||
"markdown-list-enumeration": "#2f9bce",
|
||||
"markdown-image": "#4aa8c8",
|
||||
"markdown-image-text": "#2f9bce",
|
||||
"markdown-code-block": "#4aa8c8"
|
||||
}
|
||||
},
|
||||
"dark": {
|
||||
"seeds": {
|
||||
"neutral": "#0f1419",
|
||||
"primary": "#39bae6",
|
||||
"success": "#7fd962",
|
||||
"warning": "#ebb062",
|
||||
"error": "#ff8f77",
|
||||
"info": "#73d0ff",
|
||||
"interactive": "#39bae6",
|
||||
"diffAdd": "#5cc885",
|
||||
"diffDelete": "#ff8f77"
|
||||
"primary": "#3fb7e3",
|
||||
"success": "#78d05c",
|
||||
"warning": "#e4a75c",
|
||||
"error": "#f58572",
|
||||
"info": "#66c6f1",
|
||||
"interactive": "#3fb7e3",
|
||||
"diffAdd": "#59c57c",
|
||||
"diffDelete": "#f58572"
|
||||
},
|
||||
"overrides": {
|
||||
"background-base": "#0f1419",
|
||||
"background-weak": "#121920",
|
||||
"background-strong": "#0d1116",
|
||||
"background-stronger": "#0a0e13",
|
||||
"border-weak-base": "#262c34",
|
||||
"border-weak-hover": "#2b323d",
|
||||
"border-weak-active": "#303746",
|
||||
"border-weak-selected": "#363d50",
|
||||
"border-weak-disabled": "#080b0f",
|
||||
"border-weak-focus": "#323a48",
|
||||
"border-base": "#3d4555",
|
||||
"border-hover": "#454d61",
|
||||
"border-active": "#4c556d",
|
||||
"border-selected": "#545d79",
|
||||
"border-disabled": "#0e1218",
|
||||
"border-focus": "#495368",
|
||||
"border-strong-base": "#626c81",
|
||||
"border-strong-hover": "#6c7690",
|
||||
"border-strong-active": "#76819f",
|
||||
"border-strong-selected": "#808bae",
|
||||
"border-strong-disabled": "#151b23",
|
||||
"border-strong-focus": "#6f7a96",
|
||||
"surface-diff-add-base": "#102922",
|
||||
"surface-diff-delete-base": "#2b1718",
|
||||
"surface-diff-hidden-base": "#182028",
|
||||
"text-base": "#ced0d6",
|
||||
"text-weak": "#8f9aa5",
|
||||
"text-strong": "#f6f7f9",
|
||||
"syntax-string": "#b8cc52",
|
||||
"syntax-primitive": "#f59074",
|
||||
"syntax-property": "#39bae6",
|
||||
"syntax-type": "#ebb062",
|
||||
"syntax-constant": "#73d0ff",
|
||||
"syntax-info": "#73d0ff",
|
||||
"markdown-heading": "#39bae6",
|
||||
"markdown-text": "#ced0d6",
|
||||
"markdown-link": "#39bae6",
|
||||
"markdown-link-text": "#73d0ff",
|
||||
"markdown-code": "#b8cc52",
|
||||
"markdown-block-quote": "#ebb062",
|
||||
"markdown-emph": "#ebb062",
|
||||
"markdown-strong": "#f59074",
|
||||
"markdown-horizontal-rule": "#1f2630",
|
||||
"markdown-list-item": "#39bae6",
|
||||
"markdown-list-enumeration": "#73d0ff",
|
||||
"markdown-image": "#39bae6",
|
||||
"markdown-image-text": "#73d0ff",
|
||||
"markdown-code-block": "#ced0d6"
|
||||
"background-weak": "#18222c",
|
||||
"background-strong": "#0b1015",
|
||||
"background-stronger": "#080c10",
|
||||
"surface-raised-base-hover": "#0f1419",
|
||||
"border-weak-base": "#2b3440",
|
||||
"border-weak-hover": "#323c49",
|
||||
"border-weak-active": "#394454",
|
||||
"border-weak-selected": "#415063",
|
||||
"border-weak-disabled": "#0a0e12",
|
||||
"border-weak-focus": "#374453",
|
||||
"border-base": "#475367",
|
||||
"border-hover": "#515f75",
|
||||
"border-active": "#5d6b83",
|
||||
"border-selected": "#687795",
|
||||
"border-disabled": "#11161d",
|
||||
"border-focus": "#56647c",
|
||||
"border-strong-base": "#73819b",
|
||||
"border-strong-hover": "#7f8da8",
|
||||
"border-strong-active": "#8b99b5",
|
||||
"border-strong-selected": "#98a6c3",
|
||||
"border-strong-disabled": "#1b222c",
|
||||
"border-strong-focus": "#8391ad",
|
||||
"surface-diff-add-base": "#132f27",
|
||||
"surface-diff-delete-base": "#361d20",
|
||||
"surface-diff-hidden-base": "#1b2632",
|
||||
"text-base": "#d6dae0",
|
||||
"text-weak": "#a3adba",
|
||||
"text-strong": "#fbfbfd",
|
||||
"syntax-string": "#b1c74a",
|
||||
"syntax-primitive": "#f2856f",
|
||||
"syntax-property": "#3fb7e3",
|
||||
"syntax-type": "#e4a75c",
|
||||
"syntax-constant": "#66c6f1",
|
||||
"syntax-info": "#66c6f1",
|
||||
"markdown-heading": "#3fb7e3",
|
||||
"markdown-text": "#d6dae0",
|
||||
"markdown-link": "#3fb7e3",
|
||||
"markdown-link-text": "#66c6f1",
|
||||
"markdown-code": "#b1c74a",
|
||||
"markdown-block-quote": "#e4a75c",
|
||||
"markdown-emph": "#e4a75c",
|
||||
"markdown-strong": "#f2856f",
|
||||
"markdown-horizontal-rule": "#2b3542",
|
||||
"markdown-list-item": "#3fb7e3",
|
||||
"markdown-list-enumeration": "#66c6f1",
|
||||
"markdown-image": "#3fb7e3",
|
||||
"markdown-image-text": "#66c6f1",
|
||||
"markdown-code-block": "#d6dae0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,11 @@
|
||||
"surface-inset-base-hover": "var(--smoke-light-alpha-3)",
|
||||
"surface-inset-strong": "#1f000017",
|
||||
"surface-inset-strong-hover": "#1f000017",
|
||||
"surface-raised-base": "var(--smoke-light-alpha-1)",
|
||||
"surface-raised-base": "var(--smoke-light-alpha-2)",
|
||||
"surface-float-base": "var(--smoke-dark-1)",
|
||||
"surface-float-base-hover": "var(--smoke-dark-2)",
|
||||
"surface-raised-base-hover": "var(--smoke-light-alpha-2)",
|
||||
"surface-raised-base-active": "var(--smoke-light-alpha-3)",
|
||||
"surface-raised-base-hover": "var(--smoke-light-alpha-3)",
|
||||
"surface-raised-base-active": "var(--smoke-light-alpha-4)",
|
||||
"surface-raised-strong": "var(--smoke-light-1)",
|
||||
"surface-raised-strong-hover": "var(--white)",
|
||||
"surface-raised-stronger": "var(--white)",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.1.26",
|
||||
"version": "1.1.28",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user