Compare commits
144
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 | ||
|
|
1ee8a9c0b2 | ||
|
|
4e04bee0c9 | ||
|
|
673e79f457 | ||
|
|
79ae749ed8 | ||
|
|
d605a78a05 | ||
|
|
69b3b35ea5 | ||
|
|
3173ba1288 | ||
|
|
a4d1824412 | ||
|
|
cac35bc52d | ||
|
|
ecc51ddb4e | ||
|
|
769c97af08 | ||
|
|
e29120317f | ||
|
|
88c5a7fe9e | ||
|
|
091e88c1e1 | ||
|
|
d19e76d96c | ||
|
|
c3393ecc6c | ||
|
|
889c60d63b | ||
|
|
c47699536f |
+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.25",
|
||||
"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.25",
|
||||
"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.25",
|
||||
"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.25",
|
||||
"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.25",
|
||||
"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.25",
|
||||
"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.25",
|
||||
"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.25",
|
||||
"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.25",
|
||||
"version": "1.1.28",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -358,7 +359,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.25",
|
||||
"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.25",
|
||||
"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.25",
|
||||
"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.25",
|
||||
"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.25",
|
||||
"version": "1.1.28",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -454,7 +456,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.1.25",
|
||||
"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")}`)
|
||||
}
|
||||
})
|
||||
@@ -4,10 +4,10 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>OpenCode</title>
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96-v2.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon-v2.svg" />
|
||||
<link rel="shortcut icon" href="/favicon-v2.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v2.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="#F8F7F7" />
|
||||
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.25",
|
||||
"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 @@
|
||||
../../ui/src/assets/favicon/apple-touch-icon-v2.png
|
||||
@@ -0,0 +1 @@
|
||||
../../ui/src/assets/favicon/favicon-96x96-v2.png
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../ui/src/assets/favicon/favicon-v2.ico
|
||||
+1
@@ -0,0 +1 @@
|
||||
../../ui/src/assets/favicon/favicon-v2.svg
|
||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 43 KiB |
@@ -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>
|
||||
|
||||
@@ -22,7 +22,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
const [store, setStore] = createStore({
|
||||
name: defaultName(),
|
||||
color: props.project.icon?.color || "pink",
|
||||
iconUrl: props.project.icon?.url || "",
|
||||
iconUrl: props.project.icon?.override || "",
|
||||
saving: false,
|
||||
})
|
||||
|
||||
@@ -74,7 +74,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
await globalSDK.client.project.update({
|
||||
projectID: props.project.id,
|
||||
name,
|
||||
icon: { color: store.color, url: store.iconUrl },
|
||||
icon: { color: store.color, override: store.iconUrl },
|
||||
})
|
||||
setStore("saving", false)
|
||||
dialog.close()
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
import { createSignal, createEffect, onMount, onCleanup } from "solid-js"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { markReleaseNotesSeen } from "@/lib/release-notes"
|
||||
|
||||
export interface ReleaseFeature {
|
||||
title: string
|
||||
description: string
|
||||
tag?: string
|
||||
media?: {
|
||||
type: "image" | "video"
|
||||
src: string
|
||||
alt?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface ReleaseNote {
|
||||
version: string
|
||||
features: ReleaseFeature[]
|
||||
}
|
||||
|
||||
// Current release notes - update this with each release
|
||||
export const CURRENT_RELEASE: ReleaseNote = {
|
||||
version: "1.0.0",
|
||||
features: [
|
||||
{
|
||||
title: "Cleaner tab experience",
|
||||
description: "Chat is now fixed to the side of your tabs, and review is now available as a dedicated tab. ",
|
||||
tag: "New",
|
||||
media: {
|
||||
type: "video",
|
||||
src: "/release/release-example.mp4",
|
||||
alt: "Cleaner tab experience",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Share with control",
|
||||
description: "Keep your sessions private by default, or publish them to the web with a shareable URL.",
|
||||
tag: "New",
|
||||
media: {
|
||||
type: "image",
|
||||
src: "/release/release-share.png",
|
||||
alt: "Share with control",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Improved attachment management",
|
||||
description: "Upload and manage attachments more easily, to help build and maintain context.",
|
||||
tag: "New",
|
||||
media: {
|
||||
type: "video",
|
||||
src: "/release/release-example.mp4",
|
||||
alt: "Improved attachment management",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export function DialogReleaseNotes(props: { release?: ReleaseNote }) {
|
||||
const dialog = useDialog()
|
||||
const release = props.release ?? CURRENT_RELEASE
|
||||
const [index, setIndex] = createSignal(0)
|
||||
|
||||
const feature = () => release.features[index()]
|
||||
const total = release.features.length
|
||||
const isFirst = () => index() === 0
|
||||
const isLast = () => index() === total - 1
|
||||
|
||||
function handleNext() {
|
||||
if (!isLast()) setIndex(index() + 1)
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
if (!isFirst()) setIndex(index() - 1)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
markReleaseNotesSeen()
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
let focusTrap: HTMLDivElement | undefined
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "ArrowLeft" && !isFirst()) {
|
||||
e.preventDefault()
|
||||
setIndex(index() - 1)
|
||||
}
|
||||
if (e.key === "ArrowRight" && !isLast()) {
|
||||
e.preventDefault()
|
||||
setIndex(index() + 1)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
focusTrap?.focus()
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
|
||||
})
|
||||
|
||||
// Refocus the trap when index changes to ensure escape always works
|
||||
createEffect(() => {
|
||||
index() // track index
|
||||
focusTrap?.focus()
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog class="dialog-release-notes">
|
||||
{/* Hidden element to capture initial focus and handle escape */}
|
||||
<div ref={focusTrap} tabindex="0" class="absolute opacity-0 pointer-events-none" />
|
||||
{/* Left side - Text content */}
|
||||
<div class="flex flex-col flex-1 min-w-0 p-8">
|
||||
{/* Top section - feature content (fixed position from top) */}
|
||||
<div class="flex flex-col gap-2 pt-22">
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-16-medium text-text-strong">{feature().title}</h1>
|
||||
{feature().tag && (
|
||||
<span
|
||||
class="text-12-medium text-text-weak px-1.5 py-0.5 bg-surface-base rounded-sm border border-border-weak-base"
|
||||
style={{ "border-width": "0.5px" }}
|
||||
>
|
||||
{feature().tag}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p class="text-14-regular text-text-base">{feature().description}</p>
|
||||
</div>
|
||||
|
||||
{/* Spacer to push buttons to bottom */}
|
||||
<div class="flex-1" />
|
||||
|
||||
{/* Bottom section - buttons and indicators (fixed position) */}
|
||||
<div class="flex flex-col gap-12">
|
||||
<div class="flex items-center gap-3">
|
||||
{isLast() ? (
|
||||
<Button variant="primary" size="large" onClick={handleClose}>
|
||||
Get started
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="secondary" size="large" onClick={handleNext}>
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{total > 1 && (
|
||||
<div class="flex items-center gap-1.5 -my-2.5">
|
||||
{release.features.map((_, i) => (
|
||||
<button
|
||||
type="button"
|
||||
class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
|
||||
classList={{
|
||||
"w-8": i === index(),
|
||||
"w-3": i !== index(),
|
||||
}}
|
||||
onClick={() => setIndex(i)}
|
||||
>
|
||||
<div
|
||||
class="w-full h-0.5 rounded-[1px] transition-colors duration-200"
|
||||
classList={{
|
||||
"bg-icon-strong-base": i === index(),
|
||||
"bg-icon-weak-base": i !== index(),
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Media content (edge to edge) */}
|
||||
{feature().media && (
|
||||
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
|
||||
{feature().media!.type === "image" ? (
|
||||
<img
|
||||
src={feature().media!.src}
|
||||
alt={feature().media!.alt ?? "Release preview"}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<video src={feature().media!.src} autoplay loop muted playsinline class="w-full h-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -44,7 +44,7 @@ import { ImagePreview } from "@opencode-ai/ui/image-preview"
|
||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { formatKeybind, useCommand } from "@/context/command"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
@@ -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)
|
||||
@@ -300,7 +299,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
event.stopPropagation()
|
||||
|
||||
const items = Array.from(clipboardData.items)
|
||||
const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
|
||||
const fileItems = items.filter((item) => item.kind === "file")
|
||||
const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
|
||||
|
||||
if (imageItems.length > 0) {
|
||||
for (const item of imageItems) {
|
||||
@@ -310,7 +310,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (fileItems.length > 0) {
|
||||
showToast({
|
||||
title: "Unsupported paste",
|
||||
description: "Only images or PDFs can be pasted here.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const plainText = clipboardData.getData("text/plain") ?? ""
|
||||
if (!plainText) return
|
||||
addPart({ type: "text", content: plainText, start: 0, end: 0 })
|
||||
}
|
||||
|
||||
@@ -1400,8 +1409,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
custom
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={cmd.keybind}>
|
||||
<span class="text-12-regular text-text-subtle">{formatKeybind(cmd.keybind!)}</span>
|
||||
<Show when={command.keybind(cmd.id)}>
|
||||
<span class="text-12-regular text-text-subtle">{command.keybind(cmd.id)}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { onMount } from "solid-js"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogReleaseNotes } from "./dialog-release-notes"
|
||||
import { shouldShowReleaseNotes, markReleaseNotesSeen } from "@/lib/release-notes"
|
||||
|
||||
/**
|
||||
* Component that handles showing release notes modal on app startup.
|
||||
* Shows the modal if:
|
||||
* - DEV_ALWAYS_SHOW_RELEASE_NOTES is true in lib/release-notes.ts
|
||||
* - OR the user hasn't seen the current version's release notes yet
|
||||
*
|
||||
* To disable the dev mode behavior, set DEV_ALWAYS_SHOW_RELEASE_NOTES to false
|
||||
* in packages/app/src/lib/release-notes.ts
|
||||
*/
|
||||
export function ReleaseNotesHandler() {
|
||||
const dialog = useDialog()
|
||||
|
||||
onMount(() => {
|
||||
// Small delay to ensure app is fully loaded before showing modal
|
||||
setTimeout(() => {
|
||||
if (shouldShowReleaseNotes()) {
|
||||
dialog.show(
|
||||
() => <DialogReleaseNotes />,
|
||||
() => markReleaseNotesSeen(),
|
||||
)
|
||||
}
|
||||
}, 500)
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -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,232 +0,0 @@
|
||||
import { For, createSignal, Show, onMount, onCleanup } from "solid-js"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { parseKeybind, formatKeybind } from "@/context/command"
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
|
||||
const SPECIAL_CHAR_NAMES: Record<string, string> = {
|
||||
"^": "Control",
|
||||
"⌥": "Option",
|
||||
"⇧": "Shift",
|
||||
"⌘": "Command",
|
||||
"↑": "Arrow Up",
|
||||
"↓": "Arrow Down",
|
||||
"`": "Backtick",
|
||||
"'": "Quote",
|
||||
".": "Period",
|
||||
",": "Comma",
|
||||
"/": "Slash",
|
||||
"\\": "Backslash",
|
||||
"[": "Left Bracket",
|
||||
"]": "Right Bracket",
|
||||
"-": "Minus",
|
||||
"=": "Equals",
|
||||
";": "Semicolon",
|
||||
}
|
||||
|
||||
const KEY_DISPLAY_MAP: Record<string, string> = {
|
||||
arrowup: "↑",
|
||||
arrowdown: "↓",
|
||||
arrowleft: "←",
|
||||
arrowright: "→",
|
||||
backspace: "⌫",
|
||||
}
|
||||
|
||||
interface Shortcut {
|
||||
title: string
|
||||
keybind: string
|
||||
}
|
||||
|
||||
interface ShortcutCategory {
|
||||
name: string
|
||||
shortcuts: Shortcut[]
|
||||
}
|
||||
|
||||
function isLetter(char: string): boolean {
|
||||
return /^[A-Za-z]$/.test(char)
|
||||
}
|
||||
|
||||
function getKeyChars(config: string): string[] {
|
||||
const keybinds = parseKeybind(config)
|
||||
if (keybinds.length === 0) return []
|
||||
|
||||
const kb = keybinds[0]
|
||||
const chars: string[] = []
|
||||
|
||||
if (kb.ctrl) chars.push(IS_MAC ? "^" : "Ctrl")
|
||||
if (kb.alt) chars.push(IS_MAC ? "⌥" : "Alt")
|
||||
if (kb.shift) chars.push(IS_MAC ? "⇧" : "Shift")
|
||||
if (kb.meta) chars.push(IS_MAC ? "⌘" : "Meta")
|
||||
|
||||
if (kb.key) {
|
||||
const mapped = KEY_DISPLAY_MAP[kb.key.toLowerCase()]
|
||||
if (mapped) {
|
||||
chars.push(mapped)
|
||||
} else {
|
||||
const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1)
|
||||
for (const char of displayKey) {
|
||||
chars.push(char)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return chars
|
||||
}
|
||||
|
||||
const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
|
||||
{
|
||||
name: "General",
|
||||
shortcuts: [
|
||||
{ title: "Command palette", keybind: "mod+shift+p" },
|
||||
{ title: "Toggle sidebar", keybind: "mod+b" },
|
||||
{ title: "Toggle shortcuts", keybind: "ctrl+/" },
|
||||
{ title: "Open file", keybind: "mod+p" },
|
||||
{ title: "Open project", keybind: "mod+o" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Session",
|
||||
shortcuts: [
|
||||
{ title: "New session", keybind: "mod+shift+s" },
|
||||
{ title: "Previous session", keybind: "alt+arrowup" },
|
||||
{ title: "Next session", keybind: "alt+arrowdown" },
|
||||
{ title: "Archive session", keybind: "mod+shift+backspace" },
|
||||
{ title: "Undo", keybind: "mod+z" },
|
||||
{ title: "Redo", keybind: "mod+shift+z" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Navigation",
|
||||
shortcuts: [
|
||||
{ title: "Previous message", keybind: "mod+arrowup" },
|
||||
{ title: "Next message", keybind: "mod+arrowdown" },
|
||||
{ title: "Toggle steps", keybind: "mod+e" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Model and Agent",
|
||||
shortcuts: [
|
||||
{ title: "Choose model", keybind: "mod+'" },
|
||||
{ title: "Cycle agent", keybind: "mod+." },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Terminal",
|
||||
shortcuts: [
|
||||
{ title: "Toggle terminal", keybind: "ctrl+`" },
|
||||
{ title: "New terminal", keybind: "ctrl+shift+`" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const USED_SHORTCUTS_KEY = "opencode:used-shortcuts"
|
||||
|
||||
function getUsedShortcuts(): Set<string> {
|
||||
const stored = localStorage.getItem(USED_SHORTCUTS_KEY)
|
||||
return stored ? new Set(JSON.parse(stored)) : new Set()
|
||||
}
|
||||
|
||||
const [usedShortcuts, setUsedShortcuts] = createSignal(getUsedShortcuts())
|
||||
|
||||
function formatKeybindForCopy(config: string): string {
|
||||
const chars = getKeyChars(config)
|
||||
return chars.join("")
|
||||
}
|
||||
|
||||
function ShortcutItem(props: { shortcut: Shortcut }) {
|
||||
const [copied, setCopied] = createSignal(false)
|
||||
const used = () => usedShortcuts().has(props.shortcut.keybind)
|
||||
|
||||
function copyToClipboard() {
|
||||
const text = formatKeybindForCopy(props.shortcut.keybind)
|
||||
navigator.clipboard.writeText(text)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1500)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip value="Copy to clipboard" placement="top">
|
||||
<button type="button" class="shortcut-item" classList={{ "shortcut-used": used() }} onClick={copyToClipboard}>
|
||||
<span class="text-14-regular text-text-base">{props.shortcut.title}</span>
|
||||
<Show
|
||||
when={!copied()}
|
||||
fallback={
|
||||
<div class="shortcut-copied">
|
||||
<Icon name="check" size="small" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="shortcut-keys">
|
||||
<For each={getKeyChars(props.shortcut.keybind)}>
|
||||
{(char) => {
|
||||
const tooltip = SPECIAL_CHAR_NAMES[char]
|
||||
const isSpecial = tooltip && !isLetter(char)
|
||||
const isShift = char === "⇧"
|
||||
return (
|
||||
<Show when={isSpecial} fallback={<kbd class="shortcut-key">{char}</kbd>}>
|
||||
<Tooltip value={tooltip} placement="top">
|
||||
<kbd class="shortcut-key shortcut-key-special">
|
||||
<span classList={{ "shortcut-key-shift": isShift }}>{char}</span>
|
||||
</kbd>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export function ShortcutsPanel(props: { onClose: () => void }) {
|
||||
const [activeTab, setActiveTab] = createSignal(SHORTCUT_CATEGORIES[0].name)
|
||||
let listRef: HTMLDivElement | undefined
|
||||
|
||||
onMount(() => {
|
||||
const handler = () => setUsedShortcuts(getUsedShortcuts())
|
||||
window.addEventListener("shortcut-used", handler)
|
||||
onCleanup(() => window.removeEventListener("shortcut-used", handler))
|
||||
|
||||
// Focus the active tab trigger so arrow keys work immediately
|
||||
const trigger = listRef?.querySelector<HTMLButtonElement>('[data-slot="tabs-trigger"][data-selected]')
|
||||
trigger?.focus()
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="shortcuts-panel" data-component="shortcuts-panel">
|
||||
<Tabs value={activeTab()} onChange={setActiveTab}>
|
||||
<div class="shortcuts-tabs-row">
|
||||
<Tabs.List ref={listRef} class="shortcuts-tabs-list">
|
||||
<For each={SHORTCUT_CATEGORIES}>
|
||||
{(category) => <Tabs.Trigger value={category.name}>{category.name}</Tabs.Trigger>}
|
||||
</For>
|
||||
</Tabs.List>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={
|
||||
<span>
|
||||
Close <span class="text-text-weak">{formatKeybind("ctrl+/")}</span>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<IconButton icon="close" variant="ghost" onClick={props.onClose} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<For each={SHORTCUT_CATEGORIES}>
|
||||
{(category) => (
|
||||
<Tabs.Content value={category.name} class="shortcuts-content">
|
||||
<div class="shortcuts-grid">
|
||||
<For each={category.shortcuts}>{(shortcut) => <ShortcutItem shortcut={shortcut} />}</For>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
)}
|
||||
</For>
|
||||
</Tabs>
|
||||
</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,11 +1,28 @@
|
||||
import { createMemo, createSignal, onCleanup, onMount, Show, 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 { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
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 {
|
||||
@@ -29,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 []
|
||||
|
||||
@@ -75,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
|
||||
@@ -107,96 +132,44 @@ 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)
|
||||
}
|
||||
|
||||
return IS_MAC ? parts.join("") : parts.join("+")
|
||||
}
|
||||
|
||||
function DialogCommand(props: { options: CommandOption[] }) {
|
||||
const dialog = useDialog()
|
||||
const state = { cleanup: undefined as (() => void) | void, committed: false }
|
||||
|
||||
const handleMove = (option: CommandOption | undefined) => {
|
||||
state.cleanup?.()
|
||||
if (!option) return
|
||||
state.cleanup = option.onHighlight?.()
|
||||
}
|
||||
|
||||
const handleSelect = (option: CommandOption | undefined) => {
|
||||
if (!option) return
|
||||
state.committed = true
|
||||
state.cleanup = undefined
|
||||
dialog.close()
|
||||
option.onSelect?.("palette")
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (state.committed) return
|
||||
state.cleanup?.()
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog title="Commands">
|
||||
<List
|
||||
class="px-2.5"
|
||||
search={{ placeholder: "Search commands", autofocus: true }}
|
||||
emptyMessage="No commands found"
|
||||
items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
|
||||
key={(x) => x?.id}
|
||||
filterKeys={["title", "description", "category"]}
|
||||
groupBy={(x) => x.category ?? ""}
|
||||
onMove={handleMove}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
{(option) => (
|
||||
<div class="w-full flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-14-regular text-text-strong whitespace-nowrap">{option.title}</span>
|
||||
<Show when={option.description}>
|
||||
<span class="text-14-regular text-text-weak truncate">{option.description}</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={option.keybind}>
|
||||
<span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(option.keybind!)}</span>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const USED_SHORTCUTS_KEY = "opencode:used-shortcuts"
|
||||
|
||||
function getUsedShortcuts(): Set<string> {
|
||||
const stored = localStorage.getItem(USED_SHORTCUTS_KEY)
|
||||
return stored ? new Set(JSON.parse(stored)) : new Set()
|
||||
}
|
||||
|
||||
function markShortcutUsed(keybind: string) {
|
||||
const used = getUsedShortcuts()
|
||||
used.add(keybind)
|
||||
localStorage.setItem(USED_SHORTCUTS_KEY, JSON.stringify([...used]))
|
||||
window.dispatchEvent(new CustomEvent("shortcut-used", { detail: keybind }))
|
||||
}
|
||||
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
|
||||
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[] = []
|
||||
|
||||
@@ -208,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,
|
||||
]
|
||||
})
|
||||
|
||||
@@ -232,17 +231,15 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
}
|
||||
|
||||
const showPalette = () => {
|
||||
if (dialog.active) return
|
||||
dialog.show(() => <DialogCommand options={options().filter((x) => !x.disabled)} />)
|
||||
run("file.open", "palette")
|
||||
}
|
||||
|
||||
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()
|
||||
markShortcutUsed("mod+shift+p")
|
||||
showPalette()
|
||||
return
|
||||
}
|
||||
@@ -254,7 +251,6 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
const keybinds = parseKeybind(option.keybind)
|
||||
if (matchKeybind(keybinds, event)) {
|
||||
event.preventDefault()
|
||||
markShortcutUsed(option.keybind)
|
||||
option.onSelect?.("keybind")
|
||||
return
|
||||
}
|
||||
@@ -281,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,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { batch, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
|
||||
import { batch, createEffect, createMemo, onCleanup, onMount } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { useGlobalSync } from "./global-sync"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
@@ -33,8 +33,6 @@ type SessionTabs = {
|
||||
type SessionView = {
|
||||
scroll: Record<string, SessionScroll>
|
||||
reviewOpen?: string[]
|
||||
terminalOpened?: boolean
|
||||
reviewPanelOpened?: boolean
|
||||
}
|
||||
|
||||
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
|
||||
@@ -78,9 +76,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
},
|
||||
terminal: {
|
||||
height: 280,
|
||||
opened: false,
|
||||
},
|
||||
review: {
|
||||
diffStyle: "split" as ReviewDiffStyle,
|
||||
panelOpened: true,
|
||||
},
|
||||
session: {
|
||||
width: 600,
|
||||
@@ -93,8 +93,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
}),
|
||||
)
|
||||
|
||||
const [shortcutsOpened, setShortcutsOpened] = createSignal(false)
|
||||
|
||||
const MAX_SESSION_KEYS = 50
|
||||
const meta = { active: undefined as string | undefined, pruned: false }
|
||||
const used = new Map<string, number>()
|
||||
@@ -174,7 +172,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
const current = store.sessionView[sessionKey]
|
||||
const keep = meta.active ?? sessionKey
|
||||
if (!current) {
|
||||
setStore("sessionView", sessionKey, { scroll: next, terminalOpened: false, reviewPanelOpened: true })
|
||||
setStore("sessionView", sessionKey, { scroll: next })
|
||||
prune(keep)
|
||||
return
|
||||
}
|
||||
@@ -210,10 +208,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
})
|
||||
})
|
||||
|
||||
const usedColors = new Set<AvatarColorKey>()
|
||||
const [colors, setColors] = createStore<Record<string, AvatarColorKey>>({})
|
||||
|
||||
function pickAvailableColor(): AvatarColorKey {
|
||||
const available = AVATAR_COLOR_KEYS.filter((c) => !usedColors.has(c))
|
||||
function pickAvailableColor(used: Set<string>): AvatarColorKey {
|
||||
const available = AVATAR_COLOR_KEYS.filter((c) => !used.has(c))
|
||||
if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)]
|
||||
return available[Math.floor(Math.random() * available.length)]
|
||||
}
|
||||
@@ -224,24 +222,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
const metadata = projectID
|
||||
? globalSync.data.project.find((x) => x.id === projectID)
|
||||
: globalSync.data.project.find((x) => x.worktree === project.worktree)
|
||||
return [
|
||||
{
|
||||
...(metadata ?? {}),
|
||||
...project,
|
||||
icon: { url: metadata?.icon?.url, color: metadata?.icon?.color },
|
||||
return {
|
||||
...(metadata ?? {}),
|
||||
...project,
|
||||
icon: {
|
||||
url: metadata?.icon?.url,
|
||||
override: metadata?.icon?.override,
|
||||
color: metadata?.icon?.color,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function colorize(project: LocalProject) {
|
||||
if (project.icon?.color) return project
|
||||
const color = pickAvailableColor()
|
||||
usedColors.add(color)
|
||||
project.icon = { ...project.icon, color }
|
||||
if (project.id) {
|
||||
globalSdk.client.project.update({ projectID: project.id, icon: { color } })
|
||||
}
|
||||
return project
|
||||
}
|
||||
|
||||
const roots = createMemo(() => {
|
||||
@@ -279,8 +268,37 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
})
|
||||
})
|
||||
|
||||
const enriched = createMemo(() => server.projects.list().flatMap(enrich))
|
||||
const list = createMemo(() => enriched().flatMap(colorize))
|
||||
const enriched = createMemo(() => server.projects.list().map(enrich))
|
||||
const list = createMemo(() => {
|
||||
const projects = enriched()
|
||||
return projects.map((project) => {
|
||||
const color = project.icon?.color ?? colors[project.worktree]
|
||||
if (!color) return project
|
||||
const icon = project.icon ? { ...project.icon, color } : { color }
|
||||
return { ...project, icon }
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const projects = enriched()
|
||||
if (projects.length === 0) return
|
||||
|
||||
const used = new Set<string>()
|
||||
for (const project of projects) {
|
||||
const color = project.icon?.color ?? colors[project.worktree]
|
||||
if (color) used.add(color)
|
||||
}
|
||||
|
||||
for (const project of projects) {
|
||||
if (project.icon?.color) continue
|
||||
if (colors[project.worktree]) continue
|
||||
const color = pickAvailableColor(used)
|
||||
used.add(color)
|
||||
setColors(project.worktree, color)
|
||||
if (!project.id) continue
|
||||
void globalSdk.client.project.update({ projectID: project.id, icon: { color } })
|
||||
}
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
Promise.all(
|
||||
@@ -355,18 +373,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
setStore("review", "diffStyle", diffStyle)
|
||||
},
|
||||
},
|
||||
shortcuts: {
|
||||
opened: shortcutsOpened,
|
||||
open() {
|
||||
setShortcutsOpened(true)
|
||||
},
|
||||
close() {
|
||||
setShortcutsOpened(false)
|
||||
},
|
||||
toggle() {
|
||||
setShortcutsOpened((x) => !x)
|
||||
},
|
||||
},
|
||||
session: {
|
||||
width: createMemo(() => store.session?.width ?? 600),
|
||||
resize(width: number) {
|
||||
@@ -393,31 +399,31 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
touch(sessionKey)
|
||||
scroll.seed(sessionKey)
|
||||
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
|
||||
const terminalOpened = createMemo(() => s().terminalOpened ?? false)
|
||||
const reviewPanelOpened = createMemo(() => s().reviewPanelOpened ?? true)
|
||||
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
|
||||
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
|
||||
|
||||
function setTerminalOpened(next: boolean) {
|
||||
const current = store.sessionView[sessionKey]
|
||||
const current = store.terminal
|
||||
if (!current) {
|
||||
setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: next, reviewPanelOpened: true })
|
||||
setStore("terminal", { height: 280, opened: next })
|
||||
return
|
||||
}
|
||||
|
||||
const value = current.terminalOpened ?? false
|
||||
const value = current.opened ?? false
|
||||
if (value === next) return
|
||||
setStore("sessionView", sessionKey, "terminalOpened", next)
|
||||
setStore("terminal", "opened", next)
|
||||
}
|
||||
|
||||
function setReviewPanelOpened(next: boolean) {
|
||||
const current = store.sessionView[sessionKey]
|
||||
const current = store.review
|
||||
if (!current) {
|
||||
setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: false, reviewPanelOpened: next })
|
||||
setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next })
|
||||
return
|
||||
}
|
||||
|
||||
const value = current.reviewPanelOpened ?? true
|
||||
const value = current.panelOpened ?? true
|
||||
if (value === next) return
|
||||
setStore("sessionView", sessionKey, "reviewPanelOpened", next)
|
||||
setStore("review", "panelOpened", next)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -458,8 +464,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
if (!current) {
|
||||
setStore("sessionView", sessionKey, {
|
||||
scroll: {},
|
||||
terminalOpened: false,
|
||||
reviewPanelOpened: true,
|
||||
reviewOpen: open,
|
||||
})
|
||||
return
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -37,7 +37,7 @@ const platform: Platform = {
|
||||
.then(() => {
|
||||
const notification = new Notification(title, {
|
||||
body: description ?? "",
|
||||
icon: "https://opencode.ai/favicon-96x96.png",
|
||||
icon: "https://opencode.ai/favicon-96x96-v2.png",
|
||||
})
|
||||
notification.onclick = () => {
|
||||
window.focus()
|
||||
|
||||
@@ -9,262 +9,3 @@
|
||||
*[data-tauri-drag-region] {
|
||||
app-region: drag;
|
||||
}
|
||||
|
||||
/* Wider dialog variant for release notes modal */
|
||||
[data-component="dialog"]:has(.dialog-release-notes) {
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
|
||||
[data-slot="dialog-container"] {
|
||||
width: min(100%, 720px);
|
||||
height: min(100%, 400px);
|
||||
margin-top: -80px;
|
||||
|
||||
[data-slot="dialog-content"] {
|
||||
min-height: auto;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
border: none;
|
||||
box-shadow: var(--shadow-lg-border-base);
|
||||
}
|
||||
|
||||
[data-slot="dialog-body"] {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Shortcuts panel */
|
||||
[data-component="shortcuts-panel"] {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-background-base);
|
||||
border-top: 1px solid var(--color-border-weak-base);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 280px;
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-component="tabs"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.shortcuts-tabs-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
padding: 12px 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.shortcuts-tabs-row > [data-component="icon-button"] {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-slot="tabs-list"] {
|
||||
height: auto;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-slot="tabs-list"]::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-slot="tabs-trigger-wrapper"] {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-weight: var(--font-weight-regular);
|
||||
border-radius: 6px;
|
||||
color: var(--color-text-weak);
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-slot="tabs-trigger-wrapper"]:hover {
|
||||
background: var(--color-surface-base);
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-slot="tabs-trigger-wrapper"]:has([data-selected]) {
|
||||
background: var(--color-surface-raised-base-active);
|
||||
color: var(--color-text-strong);
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-slot="tabs-trigger"] {
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-slot="tabs-trigger"]:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--shadow-xs-border-focus);
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-slot="tabs-content"] {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.shortcuts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 240px);
|
||||
gap: 4px 48px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.shortcuts-grid {
|
||||
grid-template-columns: repeat(3, 240px);
|
||||
gap: 4px 32px;
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-slot="tabs-list"] {
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.shortcuts-grid {
|
||||
grid-template-columns: repeat(2, 240px);
|
||||
gap: 4px 24px;
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-slot="tabs-list"] {
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.shortcuts-grid {
|
||||
grid-template-columns: repeat(2, minmax(160px, 1fr));
|
||||
gap: 4px 24px;
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-slot="tabs-list"] {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.shortcuts-panel [data-slot="tabs-trigger"] {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.shortcuts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.shortcut-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 6px 6px 12px;
|
||||
margin: 0 -12px;
|
||||
gap: 16px;
|
||||
height: 36px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.1s ease;
|
||||
text-align: left;
|
||||
width: calc(100% + 24px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.shortcut-item:hover {
|
||||
background: var(--color-surface-base);
|
||||
}
|
||||
|
||||
.shortcut-item:hover .shortcut-key {
|
||||
background: var(--color-background-stronger);
|
||||
}
|
||||
|
||||
.shortcut-item:active {
|
||||
background: var(--color-surface-raised-base);
|
||||
}
|
||||
|
||||
.shortcut-item.shortcut-used span {
|
||||
color: var(--color-text-interactive-base);
|
||||
}
|
||||
|
||||
.shortcut-item.shortcut-used .shortcut-key {
|
||||
color: var(--color-text-interactive-base);
|
||||
border-color: var(--color-border-interactive-base);
|
||||
background: var(--color-surface-interactive-base);
|
||||
box-shadow: var(--shadow-xs-border-interactive);
|
||||
}
|
||||
|
||||
.shortcut-keys {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.shortcut-copied {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-success-base);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.shortcut-key {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
padding: 0 6px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--color-text-subtle);
|
||||
box-shadow: var(--shadow-xs-border-base);
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shortcut-key-special {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.shortcut-key-shift {
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
|
||||
/* Adjust main content and sidebar when shortcuts panel is open */
|
||||
main.shortcuts-open {
|
||||
padding-bottom: 280px;
|
||||
}
|
||||
|
||||
.sidebar-shortcuts-open {
|
||||
padding-bottom: 280px;
|
||||
}
|
||||
|
||||
/* Adjust dialogs when shortcuts panel is open */
|
||||
body:has([data-component="shortcuts-panel"]) [data-component="dialog"] {
|
||||
bottom: 280px;
|
||||
}
|
||||
|
||||
body:has([data-component="shortcuts-panel"]) [data-component="dialog-overlay"] {
|
||||
bottom: 280px;
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { CURRENT_RELEASE } from "@/components/dialog-release-notes"
|
||||
|
||||
const STORAGE_KEY = "opencode:last-seen-version"
|
||||
|
||||
// ============================================================================
|
||||
// DEV MODE: Set this to true to always show the release notes modal on startup
|
||||
// Set to false for production behavior (only shows after updates)
|
||||
// ============================================================================
|
||||
const DEV_ALWAYS_SHOW_RELEASE_NOTES = true
|
||||
|
||||
/**
|
||||
* Check if release notes should be shown
|
||||
* Returns true if:
|
||||
* - DEV_ALWAYS_SHOW_RELEASE_NOTES is true (for development)
|
||||
* - OR the current version is newer than the last seen version
|
||||
*/
|
||||
export function shouldShowReleaseNotes(): boolean {
|
||||
if (DEV_ALWAYS_SHOW_RELEASE_NOTES) {
|
||||
console.log("[ReleaseNotes] DEV mode: always showing release notes")
|
||||
return true
|
||||
}
|
||||
|
||||
const lastSeen = localStorage.getItem(STORAGE_KEY)
|
||||
if (!lastSeen) {
|
||||
// First time user - show release notes
|
||||
return true
|
||||
}
|
||||
|
||||
// Compare versions - show if current is newer
|
||||
return CURRENT_RELEASE.version !== lastSeen
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the current release notes as seen
|
||||
* Call this when the user closes the release notes modal
|
||||
*/
|
||||
export function markReleaseNotesSeen(): void {
|
||||
localStorage.setItem(STORAGE_KEY, CURRENT_RELEASE.version)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current version
|
||||
*/
|
||||
export function getCurrentVersion(): string {
|
||||
return CURRENT_RELEASE.version
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the seen status (useful for testing)
|
||||
*/
|
||||
export function resetReleaseNotesSeen(): void {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
+262
-160
@@ -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,18 +55,18 @@ 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 { formatKeybind, useCommand, type CommandOption } from "@/context/command"
|
||||
import { DialogSettings } from "@/components/dialog-settings"
|
||||
import { useCommand, type CommandOption } from "@/context/command"
|
||||
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
|
||||
import { navStart } from "@/utils/perf"
|
||||
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
|
||||
import { DialogEditProject } from "@/components/dialog-edit-project"
|
||||
import { ReleaseNotesHandler } from "@/components/release-notes-handler"
|
||||
import { ShortcutsPanel } from "@/components/shortcuts-panel"
|
||||
import { Titlebar } from "@/components/titlebar"
|
||||
import { useServer } from "@/context/server"
|
||||
|
||||
@@ -78,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>,
|
||||
}),
|
||||
)
|
||||
@@ -87,7 +89,6 @@ export default function Layout(props: ParentProps) {
|
||||
let scrollContainerRef: HTMLDivElement | undefined
|
||||
const xlQuery = window.matchMedia("(min-width: 1280px)")
|
||||
const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches)
|
||||
const [helpDropdownWidth, setHelpDropdownWidth] = createSignal<number | undefined>(undefined)
|
||||
const handleViewportChange = (e: MediaQueryListEvent) => setIsLargeViewport(e.matches)
|
||||
xlQuery.addEventListener("change", handleViewportChange)
|
||||
onCleanup(() => xlQuery.removeEventListener("change", handleViewportChange))
|
||||
@@ -99,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()
|
||||
@@ -120,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: "" })
|
||||
@@ -197,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}
|
||||
@@ -316,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
|
||||
@@ -461,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:")
|
||||
|
||||
@@ -518,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)
|
||||
@@ -543,7 +603,7 @@ export default function Layout(props: ParentProps) {
|
||||
running: number
|
||||
}
|
||||
|
||||
const prefetchChunk = 200
|
||||
const prefetchChunk = 600
|
||||
const prefetchConcurrency = 1
|
||||
const prefetchPendingLimit = 6
|
||||
const prefetchToken = { value: 0 }
|
||||
@@ -752,13 +812,6 @@ export default function Layout(props: ParentProps) {
|
||||
keybind: "mod+b",
|
||||
onSelect: () => layout.sidebar.toggle(),
|
||||
},
|
||||
{
|
||||
id: "shortcuts.toggle",
|
||||
title: "Toggle shortcuts panel",
|
||||
category: "View",
|
||||
keybind: "ctrl+/",
|
||||
onSelect: () => layout.shortcuts.toggle(),
|
||||
},
|
||||
{
|
||||
id: "project.open",
|
||||
title: "Open project",
|
||||
@@ -857,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)
|
||||
@@ -895,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) {
|
||||
@@ -949,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)
|
||||
@@ -960,6 +1019,8 @@ export default function Layout(props: ParentProps) {
|
||||
return false
|
||||
})
|
||||
|
||||
setBusy(directory, false)
|
||||
|
||||
if (!result) return
|
||||
|
||||
layout.projects.close(directory)
|
||||
@@ -975,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 })
|
||||
@@ -991,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),
|
||||
@@ -1000,7 +1055,7 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
|
||||
if (!result) {
|
||||
dismiss()
|
||||
setBusy(directory, false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1020,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)
|
||||
@@ -1191,18 +1247,17 @@ export default function Layout(props: ParentProps) {
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
|
||||
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const project = currentProject()
|
||||
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
|
||||
@@ -1299,7 +1354,7 @@ export default function Layout(props: ParentProps) {
|
||||
<div class="size-full rounded overflow-clip">
|
||||
<Avatar
|
||||
fallback={name()}
|
||||
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
|
||||
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.override}
|
||||
{...getAvatarColors(props.project.icon?.color)}
|
||||
class="size-full rounded"
|
||||
style={
|
||||
@@ -1426,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()}
|
||||
@@ -1436,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() ?? []}
|
||||
@@ -1444,10 +1499,11 @@ export default function Layout(props: ParentProps) {
|
||||
getLabel={messageLabel}
|
||||
onMessageSelect={(message) => {
|
||||
if (!isActive()) {
|
||||
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
|
||||
sessionStorage.setItem("opencode.pendingMessage", `${props.session.id}|${message.id}`)
|
||||
navigate(`${props.slug}/session/${props.session.id}`)
|
||||
return
|
||||
}
|
||||
window.location.hash = `message-${message.id}`
|
||||
window.history.replaceState(null, "", `#message-${message.id}`)
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"))
|
||||
}}
|
||||
size="normal"
|
||||
@@ -1505,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}`
|
||||
})
|
||||
|
||||
@@ -1520,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
|
||||
@@ -1530,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)
|
||||
@@ -1551,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={{
|
||||
@@ -1609,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>
|
||||
@@ -1695,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}`
|
||||
}
|
||||
|
||||
@@ -1737,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"
|
||||
@@ -1744,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">
|
||||
@@ -1789,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>
|
||||
@@ -1894,10 +2001,21 @@ 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 (
|
||||
<div class="flex h-full w-full overflow-hidden" classList={{ "sidebar-shortcuts-open": layout.shortcuts.opened() }}>
|
||||
<div class="flex h-full w-full overflow-hidden">
|
||||
<div class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden">
|
||||
<div class="flex-1 min-h-0 w-full">
|
||||
<DragDropProvider
|
||||
@@ -1934,38 +2052,17 @@ 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
|
||||
icon="help"
|
||||
variant="ghost"
|
||||
size="large"
|
||||
onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<DropdownMenu
|
||||
placement="top-start"
|
||||
gutter={8}
|
||||
onOpenChange={(open) => {
|
||||
if (open && layout.sidebar.opened()) {
|
||||
setHelpDropdownWidth(layout.sidebar.width() - 16)
|
||||
return
|
||||
}
|
||||
if (!open) setHelpDropdownWidth(undefined)
|
||||
}}
|
||||
>
|
||||
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Help">
|
||||
<DropdownMenu.Trigger as={IconButton} icon="question-mark" variant="ghost" size="large" />
|
||||
</Tooltip>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
style={{
|
||||
width: helpDropdownWidth() ? `${helpDropdownWidth()}px` : undefined,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item onSelect={() => platform.openLink("https://opencode.ai/desktop-feedback")}>
|
||||
Submit feedback
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item class="flex justify-between gap-6" onSelect={() => layout.shortcuts.toggle()}>
|
||||
Keyboard shortcuts <span class="text-text-weaker">{formatKeybind("ctrl+/")}</span>
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2002,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>
|
||||
@@ -2040,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} />
|
||||
@@ -2060,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
|
||||
@@ -2176,17 +2281,14 @@ export default function Layout(props: ParentProps) {
|
||||
classList={{
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base": true,
|
||||
"xl:border-l xl:rounded-tl-sm": !layout.sidebar.opened(),
|
||||
"shortcuts-open": layout.shortcuts.opened(),
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
|
||||
{props.children}
|
||||
</Show>
|
||||
</main>
|
||||
</div>
|
||||
<Show when={layout.shortcuts.opened()}>
|
||||
<ShortcutsPanel onClose={() => layout.shortcuts.close()} />
|
||||
</Show>
|
||||
<Toast.Region />
|
||||
<ReleaseNotesHandler />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
|
||||
import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on, createSignal } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
@@ -167,6 +167,7 @@ export default function Page() {
|
||||
const sdk = useSDK()
|
||||
const prompt = usePrompt()
|
||||
const permission = usePermission()
|
||||
const [pendingMessage, setPendingMessage] = createSignal<string | undefined>(undefined)
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey()))
|
||||
const view = createMemo(() => layout.view(sessionKey()))
|
||||
@@ -384,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,
|
||||
@@ -455,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(),
|
||||
},
|
||||
{
|
||||
@@ -530,7 +544,7 @@ export default function Page() {
|
||||
title: "Cycle thinking effort",
|
||||
description: "Switch to the next effort level",
|
||||
category: "Model",
|
||||
keybind: "shift+mod+t",
|
||||
keybind: "shift+mod+d",
|
||||
onSelect: () => {
|
||||
local.model.variant.cycle()
|
||||
},
|
||||
@@ -823,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
|
||||
|
||||
@@ -943,17 +969,30 @@ export default function Page() {
|
||||
window.history.replaceState(null, "", `#${anchor(id)}`)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const raw = sessionStorage.getItem("opencode.pendingMessage")
|
||||
if (!raw) return
|
||||
const parts = raw.split("|")
|
||||
const pendingSessionID = parts[0]
|
||||
const messageID = parts[1]
|
||||
if (!pendingSessionID || !messageID) return
|
||||
if (pendingSessionID !== sessionID) return
|
||||
|
||||
sessionStorage.removeItem("opencode.pendingMessage")
|
||||
setPendingMessage(messageID)
|
||||
})
|
||||
|
||||
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
|
||||
const root = scroller
|
||||
if (!root) {
|
||||
el.scrollIntoView({ behavior, block: "start" })
|
||||
return
|
||||
}
|
||||
if (!root) return false
|
||||
|
||||
const a = el.getBoundingClientRect()
|
||||
const b = root.getBoundingClientRect()
|
||||
const top = a.top - b.top + root.scrollTop
|
||||
root.scrollTo({ top, behavior })
|
||||
return true
|
||||
}
|
||||
|
||||
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
|
||||
@@ -967,7 +1006,15 @@ export default function Page() {
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const el = document.getElementById(anchor(message.id))
|
||||
if (el) scrollToElement(el, behavior)
|
||||
if (!el) {
|
||||
requestAnimationFrame(() => {
|
||||
const next = document.getElementById(anchor(message.id))
|
||||
if (!next) return
|
||||
scrollToElement(next, behavior)
|
||||
})
|
||||
return
|
||||
}
|
||||
scrollToElement(el, behavior)
|
||||
})
|
||||
|
||||
updateHash(message.id)
|
||||
@@ -975,10 +1022,57 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const el = document.getElementById(anchor(message.id))
|
||||
if (el) scrollToElement(el, behavior)
|
||||
if (!el) {
|
||||
updateHash(message.id)
|
||||
requestAnimationFrame(() => {
|
||||
const next = document.getElementById(anchor(message.id))
|
||||
if (!next) return
|
||||
if (!scrollToElement(next, behavior)) return
|
||||
})
|
||||
return
|
||||
}
|
||||
if (scrollToElement(el, behavior)) {
|
||||
updateHash(message.id)
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const next = document.getElementById(anchor(message.id))
|
||||
if (!next) return
|
||||
if (!scrollToElement(next, behavior)) return
|
||||
})
|
||||
updateHash(message.id)
|
||||
}
|
||||
|
||||
const applyHash = (behavior: ScrollBehavior) => {
|
||||
const hash = window.location.hash.slice(1)
|
||||
if (!hash) {
|
||||
autoScroll.forceScrollToBottom()
|
||||
return
|
||||
}
|
||||
|
||||
const match = hash.match(/^message-(.+)$/)
|
||||
if (match) {
|
||||
const msg = visibleUserMessages().find((m) => m.id === match[1])
|
||||
if (msg) {
|
||||
scrollToMessage(msg, behavior)
|
||||
return
|
||||
}
|
||||
|
||||
// If we have a message hash but the message isn't loaded/rendered yet,
|
||||
// don't fall back to "bottom". We'll retry once messages arrive.
|
||||
return
|
||||
}
|
||||
|
||||
const target = document.getElementById(hash)
|
||||
if (target) {
|
||||
scrollToElement(target, behavior)
|
||||
return
|
||||
}
|
||||
|
||||
autoScroll.forceScrollToBottom()
|
||||
}
|
||||
|
||||
const getActiveMessageId = (container: HTMLDivElement) => {
|
||||
const cutoff = container.scrollTop + 100
|
||||
const nodes = container.querySelectorAll<HTMLElement>("[data-message-id]")
|
||||
@@ -1019,31 +1113,47 @@ export default function Page() {
|
||||
if (!sessionID || !ready) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const hash = window.location.hash.slice(1)
|
||||
if (!hash) {
|
||||
autoScroll.forceScrollToBottom()
|
||||
return
|
||||
}
|
||||
|
||||
const hashTarget = document.getElementById(hash)
|
||||
if (hashTarget) {
|
||||
scrollToElement(hashTarget, "auto")
|
||||
return
|
||||
}
|
||||
|
||||
const match = hash.match(/^message-(.+)$/)
|
||||
if (match) {
|
||||
const msg = visibleUserMessages().find((m) => m.id === match[1])
|
||||
if (msg) {
|
||||
scrollToMessage(msg, "auto")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
autoScroll.forceScrollToBottom()
|
||||
applyHash("auto")
|
||||
})
|
||||
})
|
||||
|
||||
// Retry message navigation once the target message is actually loaded.
|
||||
createEffect(() => {
|
||||
const sessionID = params.id
|
||||
const ready = messagesReady()
|
||||
if (!sessionID || !ready) return
|
||||
|
||||
// dependencies
|
||||
visibleUserMessages().length
|
||||
store.turnStart
|
||||
|
||||
const targetId =
|
||||
pendingMessage() ??
|
||||
(() => {
|
||||
const hash = window.location.hash.slice(1)
|
||||
const match = hash.match(/^message-(.+)$/)
|
||||
if (!match) return undefined
|
||||
return match[1]
|
||||
})()
|
||||
if (!targetId) return
|
||||
if (store.messageId === targetId) return
|
||||
|
||||
const msg = visibleUserMessages().find((m) => m.id === targetId)
|
||||
if (!msg) return
|
||||
if (pendingMessage() === targetId) setPendingMessage(undefined)
|
||||
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const sessionID = params.id
|
||||
const ready = messagesReady()
|
||||
if (!sessionID || !ready) return
|
||||
|
||||
const handler = () => requestAnimationFrame(() => applyHash("auto"))
|
||||
window.addEventListener("hashchange", handler)
|
||||
onCleanup(() => window.removeEventListener("hashchange", handler))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
document.addEventListener("keydown", handleKeyDown)
|
||||
})
|
||||
@@ -1163,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>
|
||||
@@ -1255,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
|
||||
@@ -1439,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,11 +16,15 @@ 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
|
||||
}
|
||||
@@ -28,13 +32,53 @@ function quota(error: unknown) {
|
||||
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
|
||||
return true
|
||||
} catch (error) {
|
||||
if (!quota(error)) throw error
|
||||
}
|
||||
@@ -42,9 +86,12 @@ function write(storage: Storage, key: string, value: string) {
|
||||
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) {
|
||||
@@ -99,17 +146,64 @@ function localStorageWithPrefix(prefix: string): SyncStorage {
|
||||
const base = `${prefix}:`
|
||||
const item = (key: string) => base + key
|
||||
return {
|
||||
getItem: (key) => localStorage.getItem(item(key)),
|
||||
setItem: (key, value) => write(localStorage, item(key), value),
|
||||
removeItem: (key) => localStorage.removeItem(item(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) => localStorage.getItem(key),
|
||||
setItem: (key, value) => write(localStorage, key, value),
|
||||
removeItem: (key) => localStorage.removeItem(key),
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.25",
|
||||
"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.25",
|
||||
"version": "1.1.28",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.28",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -35,7 +35,7 @@ export const subjects = createSubjects({
|
||||
|
||||
const MY_THEME: Theme = {
|
||||
...THEME_OPENAUTH,
|
||||
logo: "https://opencode.ai/favicon.svg",
|
||||
logo: "https://opencode.ai/favicon-v2.svg",
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.25",
|
||||
"version": "1.1.28",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>OpenCode</title>
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96-v2.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon-v2.svg" />
|
||||
<link rel="shortcut icon" href="/favicon-v2.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v2.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="#F8F7F7" />
|
||||
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.1.25",
|
||||
"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 = {
|
||||
@@ -253,7 +269,7 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
|
||||
.then(() => {
|
||||
const notification = new Notification(title, {
|
||||
body: description ?? "",
|
||||
icon: "https://opencode.ai/favicon-96x96.png",
|
||||
icon: "https://opencode.ai/favicon-96x96-v2.png",
|
||||
})
|
||||
notification.onclick = () => {
|
||||
const win = getCurrentWindow()
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -7,7 +7,7 @@
|
||||
"light": "#07C983",
|
||||
"dark": "#15803D"
|
||||
},
|
||||
"favicon": "/favicon.svg",
|
||||
"favicon": "/favicon-v2.svg",
|
||||
"navigation": {
|
||||
"tabs": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.06145 23.1079C5.26816 22.3769 -3.39077 20.6274 1.4173 5.06384C9.6344 6.09939 16.9728 14.0644 9.06145 23.1079Z" fill="url(#paint0_linear_17557_2021)"/>
|
||||
<path d="M8.91928 23.0939C5.27642 21.2223 0.78371 4.20891 17.0071 0C20.7569 7.19341 19.6212 16.5452 8.91928 23.0939Z" fill="url(#paint1_linear_17557_2021)"/>
|
||||
<path d="M8.91388 23.0788C8.73534 19.8817 10.1585 9.08525 23.5699 13.1107C23.1812 20.1229 18.984 26.4182 8.91388 23.0788Z" fill="url(#paint2_linear_17557_2021)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_17557_2021" x1="3.77557" y1="5.91571" x2="5.23185" y2="21.5589" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#18E299"/>
|
||||
<stop offset="1" stop-color="#15803D"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_17557_2021" x1="12.1711" y1="-0.718425" x2="10.1897" y2="22.9832" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#16A34A"/>
|
||||
<stop offset="1" stop-color="#4ADE80"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_17557_2021" x1="23.1327" y1="15.353" x2="9.33841" y2="18.5196" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#4ADE80"/>
|
||||
<stop offset="1" stop-color="#0D9373"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.25",
|
||||
"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.25"
|
||||
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.25/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.25/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.25/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.25/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.25/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.25",
|
||||
"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.25",
|
||||
"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,
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import { createMemo, For } from "solid-js"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { TIPS } from "./tips"
|
||||
import { EmptyBorder } from "./border"
|
||||
|
||||
const tip = TIPS[Math.floor(Math.random() * TIPS.length)]
|
||||
|
||||
type TipPart = { text: string; highlight: boolean }
|
||||
|
||||
function parseTip(tip: string): TipPart[] {
|
||||
const parts: TipPart[] = []
|
||||
const regex = /\{highlight\}(.*?)\{\/highlight\}/g
|
||||
let lastIndex = 0
|
||||
let match
|
||||
|
||||
while ((match = regex.exec(tip)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push({ text: tip.slice(lastIndex, match.index), highlight: false })
|
||||
}
|
||||
parts.push({ text: match[1], highlight: true })
|
||||
lastIndex = regex.lastIndex
|
||||
}
|
||||
|
||||
if (lastIndex < tip.length) {
|
||||
parts.push({ text: tip.slice(lastIndex), highlight: false })
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
const tipParts = parseTip(tip)
|
||||
|
||||
const BOX_WIDTH = 42
|
||||
const TITLE = " 🅘 Did you know? "
|
||||
|
||||
export function DidYouKnow() {
|
||||
const { theme } = useTheme()
|
||||
|
||||
const dashes = createMemo(() => {
|
||||
// ╭─ + title + ─...─ + ╮ = BOX_WIDTH
|
||||
// 1 + 1 + title.length + dashes + 1 = BOX_WIDTH
|
||||
return Math.max(0, BOX_WIDTH - 2 - TITLE.length - 1)
|
||||
})
|
||||
|
||||
return (
|
||||
<box position="absolute" bottom={3} right={2} width={BOX_WIDTH}>
|
||||
<text>
|
||||
<span style={{ fg: theme.border }}>╭─</span>
|
||||
<span style={{ fg: theme.text }}>{TITLE}</span>
|
||||
<span style={{ fg: theme.border }}>{"─".repeat(dashes())}╮</span>
|
||||
</text>
|
||||
<box
|
||||
border={["left", "right", "bottom"]}
|
||||
borderColor={theme.border}
|
||||
customBorderChars={{
|
||||
...EmptyBorder,
|
||||
bottomLeft: "╰",
|
||||
bottomRight: "╯",
|
||||
horizontal: "─",
|
||||
vertical: "│",
|
||||
}}
|
||||
>
|
||||
<box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
|
||||
<text>
|
||||
<For each={tipParts}>
|
||||
{(part) => <span style={{ fg: part.highlight ? theme.text : theme.textMuted }}>{part.text}</span>}
|
||||
</For>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
<box flexDirection="row" justifyContent="flex-end">
|
||||
<text>
|
||||
<span style={{ fg: theme.text }}>ctrl+h</span>
|
||||
<span style={{ fg: theme.textMuted }}> hide tips</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export function ShowTipsHint() {
|
||||
const { theme } = useTheme()
|
||||
|
||||
return (
|
||||
<box position="absolute" bottom={3} right={2}>
|
||||
<box flexDirection="row" justifyContent="flex-end">
|
||||
<text>
|
||||
<span style={{ fg: theme.text }}>ctrl+h</span>
|
||||
<span style={{ fg: theme.textMuted }}> show tips</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -85,6 +85,7 @@ export function Autocomplete(props: {
|
||||
index: 0,
|
||||
selected: 0,
|
||||
visible: false as AutocompleteRef["visible"],
|
||||
input: "keyboard" as "keyboard" | "mouse",
|
||||
})
|
||||
|
||||
const [positionTick, setPositionTick] = createSignal(0)
|
||||
@@ -128,6 +129,14 @@ export function Autocomplete(props: {
|
||||
return props.input().getTextRange(store.index + 1, props.input().cursorOffset)
|
||||
})
|
||||
|
||||
// When the filter changes due to how TUI works, the mousemove might still be triggered
|
||||
// via a synthetic event as the layout moves underneath the cursor. This is a workaround to make sure the input mode remains keyboard so
|
||||
// that the mouseover event doesn't trigger when filtering.
|
||||
createEffect(() => {
|
||||
filter()
|
||||
setStore("input", "keyboard")
|
||||
})
|
||||
|
||||
function insertPart(text: string, part: PromptInfo["parts"][number]) {
|
||||
const input = props.input()
|
||||
const currentCursorOffset = input.cursorOffset
|
||||
@@ -525,11 +534,13 @@ export function Autocomplete(props: {
|
||||
const isNavDown = name === "down" || (ctrlOnly && name === "n")
|
||||
|
||||
if (isNavUp) {
|
||||
setStore("input", "keyboard")
|
||||
move(-1)
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (isNavDown) {
|
||||
setStore("input", "keyboard")
|
||||
move(1)
|
||||
e.preventDefault()
|
||||
return
|
||||
@@ -612,7 +623,17 @@ export function Autocomplete(props: {
|
||||
paddingRight={1}
|
||||
backgroundColor={index === store.selected ? theme.primary : undefined}
|
||||
flexDirection="row"
|
||||
onMouseOver={() => moveTo(index)}
|
||||
onMouseMove={() => {
|
||||
setStore("input", "mouse")
|
||||
}}
|
||||
onMouseOver={() => {
|
||||
if (store.input !== "mouse") return
|
||||
moveTo(index)
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
setStore("input", "mouse")
|
||||
moveTo(index)
|
||||
}}
|
||||
onMouseUp={() => select()}
|
||||
>
|
||||
<text fg={index === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
export const TIPS = [
|
||||
"Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files to your prompt.",
|
||||
"Start a message with {highlight}!{/highlight} to run shell commands directly (e.g., {highlight}!ls -la{/highlight}).",
|
||||
"Press {highlight}Tab{/highlight} to cycle between Build (full access) and Plan (read-only) agents.",
|
||||
"Use {highlight}/undo{/highlight} to revert the last message and any file changes made by OpenCode.",
|
||||
"Use {highlight}/redo{/highlight} to restore previously undone messages and file changes.",
|
||||
"Run {highlight}/share{/highlight} to create a public link to your conversation at opencode.ai.",
|
||||
"Drag and drop images into the terminal to add them as context for your prompts.",
|
||||
"Press {highlight}Ctrl+V{/highlight} to paste images from your clipboard directly into the prompt.",
|
||||
"Press {highlight}Ctrl+X E{/highlight} or {highlight}/editor{/highlight} to compose messages in your external editor.",
|
||||
"Run {highlight}/init{/highlight} to auto-generate project rules based on your codebase structure.",
|
||||
"Run {highlight}/models{/highlight} or {highlight}Ctrl+X M{/highlight} to see and switch between available AI models.",
|
||||
"Use {highlight}/theme{/highlight} or {highlight}Ctrl+X T{/highlight} to preview and switch between 50+ built-in themes.",
|
||||
"Press {highlight}Ctrl+X N{/highlight} or {highlight}/new{/highlight} to start a fresh conversation session.",
|
||||
"Use {highlight}/sessions{/highlight} or {highlight}Ctrl+X L{/highlight} to list and continue previous conversations.",
|
||||
"Run {highlight}/compact{/highlight} to summarize long sessions when approaching context limits.",
|
||||
"Press {highlight}Ctrl+X X{/highlight} or {highlight}/export{/highlight} to save the conversation as Markdown.",
|
||||
"Press {highlight}Ctrl+X Y{/highlight} to copy the assistant's last message to clipboard.",
|
||||
"Press {highlight}Ctrl+P{/highlight} to see all available actions and commands.",
|
||||
"Run {highlight}/connect{/highlight} to add API keys for 75+ supported LLM providers.",
|
||||
"The default leader key is {highlight}Ctrl+X{/highlight}; combine with other keys for quick actions.",
|
||||
"Press {highlight}F2{/highlight} to quickly switch between recently used models.",
|
||||
"Press {highlight}Ctrl+X B{/highlight} to show/hide the sidebar panel.",
|
||||
"Use {highlight}PageUp{/highlight}/{highlight}PageDown{/highlight} to navigate through conversation history.",
|
||||
"Press {highlight}Ctrl+G{/highlight} or {highlight}Home{/highlight} to jump to the beginning of the conversation.",
|
||||
"Press {highlight}Ctrl+Alt+G{/highlight} or {highlight}End{/highlight} to jump to the most recent message.",
|
||||
"Press {highlight}Shift+Enter{/highlight} or {highlight}Ctrl+J{/highlight} to add newlines in your prompt.",
|
||||
"Press {highlight}Ctrl+C{/highlight} when typing to clear the input field.",
|
||||
"Press {highlight}Escape{/highlight} to stop the AI mid-response.",
|
||||
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes.",
|
||||
"Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents.",
|
||||
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions.",
|
||||
"Create {highlight}opencode.json{/highlight} in project root for project-specific settings.",
|
||||
"Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config.",
|
||||
"Add {highlight}$schema{/highlight} to your config for autocomplete in your editor.",
|
||||
"Configure {highlight}model{/highlight} in config to set your default model.",
|
||||
"Override any keybind in config via the {highlight}keybinds{/highlight} section.",
|
||||
"Set any keybind to {highlight}none{/highlight} to disable it completely.",
|
||||
"Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section.",
|
||||
"OpenCode auto-handles OAuth for remote MCP servers requiring auth.",
|
||||
"Add {highlight}.md{/highlight} files to {highlight}.opencode/command/{/highlight} to define reusable custom prompts.",
|
||||
"Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input.",
|
||||
"Use backticks in commands to inject shell output (e.g., {highlight}`git status`{/highlight}).",
|
||||
"Add {highlight}.md{/highlight} files to {highlight}.opencode/agent/{/highlight} for specialized AI personas.",
|
||||
"Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}bash{/highlight}, and {highlight}webfetch{/highlight} tools.",
|
||||
'Use patterns like {highlight}"git *": "allow"{/highlight} for granular bash permissions.',
|
||||
'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands.',
|
||||
'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing.',
|
||||
"OpenCode auto-formats files using prettier, gofmt, ruff, and more.",
|
||||
'Set {highlight}"formatter": false{/highlight} in config to disable all auto-formatting.',
|
||||
"Define custom formatter commands with file extensions in config.",
|
||||
"OpenCode uses LSP servers for intelligent code analysis.",
|
||||
"Create {highlight}.ts{/highlight} files in {highlight}.opencode/tool/{/highlight} to define new LLM tools.",
|
||||
"Tool definitions can invoke scripts written in Python, Go, etc.",
|
||||
"Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugin/{/highlight} for event hooks.",
|
||||
"Use plugins to send OS notifications when sessions complete.",
|
||||
"Create a plugin to prevent OpenCode from reading sensitive files.",
|
||||
"Use {highlight}opencode run{/highlight} for non-interactive scripting.",
|
||||
"Use {highlight}opencode run --continue{/highlight} to resume the last session.",
|
||||
"Use {highlight}opencode run -f file.ts{/highlight} to attach files via CLI.",
|
||||
"Use {highlight}--format json{/highlight} for machine-readable output in scripts.",
|
||||
"Run {highlight}opencode serve{/highlight} for headless API access to OpenCode.",
|
||||
"Use {highlight}opencode run --attach{/highlight} to connect to a running server for faster runs.",
|
||||
"Run {highlight}opencode upgrade{/highlight} to update to the latest version.",
|
||||
"Run {highlight}opencode auth list{/highlight} to see all configured providers.",
|
||||
"Run {highlight}opencode agent create{/highlight} for guided agent creation.",
|
||||
"Use {highlight}/opencode{/highlight} in GitHub issues/PRs to trigger AI actions.",
|
||||
"Run {highlight}opencode github install{/highlight} to set up the GitHub workflow.",
|
||||
"Comment {highlight}/opencode fix this{/highlight} on issues to auto-create PRs.",
|
||||
"Comment {highlight}/oc{/highlight} on PR code lines for targeted code reviews.",
|
||||
'Use {highlight}"theme": "system"{/highlight} to match your terminal\'s colors.',
|
||||
"Create JSON theme files in {highlight}.opencode/themes/{/highlight} directory.",
|
||||
"Themes support dark/light variants for both modes.",
|
||||
"Reference ANSI colors 0-255 in custom themes.",
|
||||
"Use {highlight}{env:VAR_NAME}{/highlight} syntax to reference environment variables in config.",
|
||||
"Use {highlight}{file:path}{/highlight} to include file contents in config values.",
|
||||
"Use {highlight}instructions{/highlight} in config to load additional rules files.",
|
||||
"Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative).",
|
||||
"Configure {highlight}maxSteps{/highlight} to limit agentic iterations per request.",
|
||||
'Set {highlight}"tools": {"bash": false}{/highlight} to disable specific tools.',
|
||||
'Use {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server.',
|
||||
"Override global tool settings per agent configuration.",
|
||||
'Set {highlight}"share": "auto"{/highlight} to automatically share all sessions.',
|
||||
'Set {highlight}"share": "disabled"{/highlight} to prevent any session sharing.',
|
||||
"Run {highlight}/unshare{/highlight} to remove a session from public access.",
|
||||
"Permission {highlight}doom_loop{/highlight} prevents infinite tool call loops.",
|
||||
"Permission {highlight}external_directory{/highlight} protects files outside project.",
|
||||
"Run {highlight}opencode debug config{/highlight} to troubleshoot configuration.",
|
||||
"Use {highlight}--print-logs{/highlight} flag to see detailed logs in stderr.",
|
||||
"Press {highlight}Ctrl+X G{/highlight} or {highlight}/timeline{/highlight} to jump to specific messages.",
|
||||
"Press {highlight}Ctrl+X H{/highlight} to toggle code block visibility in messages.",
|
||||
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info.",
|
||||
"Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling.",
|
||||
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight}).",
|
||||
"Run {highlight}docker run -it --rm ghcr.io/sst/opencode{/highlight} for containerized use.",
|
||||
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models.",
|
||||
"Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing.",
|
||||
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs.",
|
||||
"Run {highlight}/help{/highlight} or {highlight}Ctrl+X H{/highlight} to show the help dialog.",
|
||||
"Use {highlight}/details{/highlight} to toggle tool execution details visibility.",
|
||||
"Use {highlight}/rename{/highlight} to rename the current session.",
|
||||
"Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell.",
|
||||
]
|
||||
@@ -106,7 +106,7 @@ const TIPS = [
|
||||
"Use plugins to send OS notifications when sessions complete",
|
||||
"Create a plugin to prevent OpenCode from reading sensitive files",
|
||||
"Use {highlight}opencode run{/highlight} for non-interactive scripting",
|
||||
"Use {highlight}opencode run --continue{/highlight} to resume the last session",
|
||||
"Use {highlight}opencode --continue{/highlight} to resume the last session",
|
||||
"Use {highlight}opencode run -f file.ts{/highlight} to attach files via CLI",
|
||||
"Use {highlight}--format json{/highlight} for machine-readable output in scripts",
|
||||
"Run {highlight}opencode serve{/highlight} for headless API access to OpenCode",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Prompt, type PromptRef } from "@tui/component/prompt"
|
||||
import { createMemo, Match, onMount, Show, Switch } from "solid-js"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { useKeybind } from "@tui/context/keybind"
|
||||
import { Logo } from "../component/logo"
|
||||
import { Tips } from "../component/tips"
|
||||
import { DidYouKnow, ShowTipsHint } from "../component/did-you-know"
|
||||
import { Locale } from "@/util/locale"
|
||||
import { useSync } from "../context/sync"
|
||||
import { Toast } from "../ui/toast"
|
||||
@@ -43,22 +42,6 @@ export function Home() {
|
||||
return !tipsHidden()
|
||||
})
|
||||
|
||||
function hideTips() {
|
||||
kv.set("tips_hidden", true)
|
||||
}
|
||||
|
||||
function enableTips() {
|
||||
kv.set("tips_hidden", false)
|
||||
}
|
||||
|
||||
function toggleTips() {
|
||||
if (showTips()) {
|
||||
hideTips()
|
||||
return
|
||||
}
|
||||
enableTips()
|
||||
}
|
||||
|
||||
command.register(() => [
|
||||
{
|
||||
title: tipsHidden() ? "Show tips" : "Hide tips",
|
||||
@@ -66,20 +49,12 @@ export function Home() {
|
||||
keybind: "tips_toggle",
|
||||
category: "System",
|
||||
onSelect: (dialog) => {
|
||||
toggleTips()
|
||||
kv.set("tips_hidden", !tipsHidden())
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
useKeyboard((evt) => {
|
||||
// Don't handle tips keybind for first-time users
|
||||
if (isFirstTimeUser()) return
|
||||
if (evt.name !== "h" || !evt.ctrl || evt.meta || evt.shift) return
|
||||
toggleTips()
|
||||
evt.preventDefault()
|
||||
})
|
||||
|
||||
const Hint = (
|
||||
<Show when={connectedMcpCount() > 0}>
|
||||
<box flexShrink={0} flexDirection="row" gap={1}>
|
||||
@@ -114,6 +89,8 @@ export function Home() {
|
||||
})
|
||||
const directory = useDirectory()
|
||||
|
||||
const keybind = useKeybind()
|
||||
|
||||
return (
|
||||
<>
|
||||
<box flexGrow={1} justifyContent="center" alignItems="center" paddingLeft={2} paddingRight={2} gap={1}>
|
||||
@@ -135,11 +112,6 @@ export function Home() {
|
||||
</box>
|
||||
<Toast />
|
||||
</box>
|
||||
<Show when={!isFirstTimeUser()}>
|
||||
<Show when={showTips()} fallback={<ShowTipsHint />}>
|
||||
<DidYouKnow />
|
||||
</Show>
|
||||
</Show>
|
||||
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}>
|
||||
<text fg={theme.textMuted}>{directory()}</text>
|
||||
<box gap={1} flexDirection="row" flexShrink={0}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -52,6 +52,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
const [store, setStore] = createStore({
|
||||
selected: 0,
|
||||
filter: "",
|
||||
input: "keyboard" as "keyboard" | "mouse",
|
||||
})
|
||||
|
||||
createEffect(
|
||||
@@ -83,6 +84,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
return result
|
||||
})
|
||||
|
||||
// When the filter changes due to how TUI works, the mousemove might still be triggered
|
||||
// via a synthetic event as the layout moves underneath the cursor. This is a workaround to make sure the input mode remains keyboard
|
||||
// that the mouseover event doesn't trigger when filtering.
|
||||
createEffect(() => {
|
||||
filtered()
|
||||
setStore("input", "keyboard")
|
||||
})
|
||||
|
||||
const grouped = createMemo(() => {
|
||||
const result = pipe(
|
||||
filtered(),
|
||||
@@ -157,12 +166,15 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
|
||||
const keybind = useKeybind()
|
||||
useKeyboard((evt) => {
|
||||
setStore("input", "keyboard")
|
||||
|
||||
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1)
|
||||
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
|
||||
if (evt.name === "pageup") move(-10)
|
||||
if (evt.name === "pagedown") move(10)
|
||||
if (evt.name === "home") moveTo(0)
|
||||
if (evt.name === "end") moveTo(flat().length - 1)
|
||||
|
||||
if (evt.name === "return") {
|
||||
const option = selected()
|
||||
if (option) {
|
||||
@@ -259,11 +271,20 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
<box
|
||||
id={JSON.stringify(option.value)}
|
||||
flexDirection="row"
|
||||
onMouseMove={() => {
|
||||
setStore("input", "mouse")
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
option.onSelect?.(dialog)
|
||||
props.onSelect?.(option)
|
||||
}}
|
||||
onMouseOver={() => {
|
||||
if (store.input !== "mouse") return
|
||||
const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
|
||||
if (index === -1) return
|
||||
moveTo(index)
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
|
||||
if (index === -1) return
|
||||
moveTo(index)
|
||||
@@ -337,6 +358,7 @@ function Option(props: {
|
||||
fg={props.active ? fg : props.current ? theme.primary : theme.text}
|
||||
attributes={props.active ? TextAttributes.BOLD : undefined}
|
||||
overflow="hidden"
|
||||
wrapMode="none"
|
||||
paddingLeft={3}
|
||||
>
|
||||
{Locale.truncate(props.title, 61)}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -162,34 +162,32 @@ export namespace Ripgrep {
|
||||
})
|
||||
}
|
||||
if (config.extension === "zip") {
|
||||
if (config.extension === "zip") {
|
||||
const zipFileReader = new ZipReader(new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()])))
|
||||
const entries = await zipFileReader.getEntries()
|
||||
let rgEntry: any
|
||||
for (const entry of entries) {
|
||||
if (entry.filename.endsWith("rg.exe")) {
|
||||
rgEntry = entry
|
||||
break
|
||||
}
|
||||
const zipFileReader = new ZipReader(new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()])))
|
||||
const entries = await zipFileReader.getEntries()
|
||||
let rgEntry: any
|
||||
for (const entry of entries) {
|
||||
if (entry.filename.endsWith("rg.exe")) {
|
||||
rgEntry = entry
|
||||
break
|
||||
}
|
||||
|
||||
if (!rgEntry) {
|
||||
throw new ExtractionFailedError({
|
||||
filepath: archivePath,
|
||||
stderr: "rg.exe not found in zip archive",
|
||||
})
|
||||
}
|
||||
|
||||
const rgBlob = await rgEntry.getData(new BlobWriter())
|
||||
if (!rgBlob) {
|
||||
throw new ExtractionFailedError({
|
||||
filepath: archivePath,
|
||||
stderr: "Failed to extract rg.exe from zip archive",
|
||||
})
|
||||
}
|
||||
await Bun.write(filepath, await rgBlob.arrayBuffer())
|
||||
await zipFileReader.close()
|
||||
}
|
||||
|
||||
if (!rgEntry) {
|
||||
throw new ExtractionFailedError({
|
||||
filepath: archivePath,
|
||||
stderr: "rg.exe not found in zip archive",
|
||||
})
|
||||
}
|
||||
|
||||
const rgBlob = await rgEntry.getData(new BlobWriter())
|
||||
if (!rgBlob) {
|
||||
throw new ExtractionFailedError({
|
||||
filepath: archivePath,
|
||||
stderr: "Failed to extract rg.exe from zip archive",
|
||||
})
|
||||
}
|
||||
await Bun.write(filepath, await rgBlob.arrayBuffer())
|
||||
await zipFileReader.close()
|
||||
}
|
||||
await fs.unlink(archivePath)
|
||||
if (!platformKey.endsWith("-win32")) await fs.chmod(filepath, 0o755)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -25,6 +25,7 @@ export namespace Project {
|
||||
icon: z
|
||||
.object({
|
||||
url: z.string().optional(),
|
||||
override: z.string().optional(),
|
||||
color: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
@@ -190,6 +191,7 @@ export namespace Project {
|
||||
if (!existing.sandboxes) existing.sandboxes = []
|
||||
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing)
|
||||
|
||||
const result: Info = {
|
||||
...existing,
|
||||
worktree,
|
||||
@@ -213,6 +215,7 @@ export namespace Project {
|
||||
|
||||
export async function discover(input: Info) {
|
||||
if (input.vcs !== "git") return
|
||||
if (input.icon?.override) return
|
||||
if (input.icon?.url) return
|
||||
const glob = new Bun.Glob("**/{favicon}.{ico,png,svg,jpg,jpeg,webp}")
|
||||
const matches = await Array.fromAsync(
|
||||
@@ -293,6 +296,7 @@ export namespace Project {
|
||||
...draft.icon,
|
||||
}
|
||||
if (input.icon.url !== undefined) draft.icon.url = input.icon.url
|
||||
if (input.icon.override !== undefined) draft.icon.override = input.icon.override || undefined
|
||||
if (input.icon.color !== undefined) draft.icon.color = input.icon.color
|
||||
}
|
||||
draft.time.updated = Date.now()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -33,8 +33,8 @@ export const BatchTool = Tool.define("batch", async () => {
|
||||
const { Session } = await import("../session")
|
||||
const { Identifier } = await import("../id/id")
|
||||
|
||||
const toolCalls = params.tool_calls.slice(0, 10)
|
||||
const discardedCalls = params.tool_calls.slice(10)
|
||||
const toolCalls = params.tool_calls.slice(0, 25)
|
||||
const discardedCalls = params.tool_calls.slice(25)
|
||||
|
||||
const { ToolRegistry } = await import("./registry")
|
||||
const availableTools = await ToolRegistry.tools({ modelID: "", providerID: "" })
|
||||
@@ -139,14 +139,14 @@ export const BatchTool = Tool.define("batch", async () => {
|
||||
state: {
|
||||
status: "error",
|
||||
input: call.parameters,
|
||||
error: "Maximum of 10 tools allowed in batch",
|
||||
error: "Maximum of 25 tools allowed in batch",
|
||||
time: { start: now, end: now },
|
||||
},
|
||||
})
|
||||
results.push({
|
||||
success: false as const,
|
||||
tool: call.tool,
|
||||
error: new Error("Maximum of 10 tools allowed in batch"),
|
||||
error: new Error("Maximum of 25 tools allowed in batch"),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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–10 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.25",
|
||||
"version": "1.1.28",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
@@ -25,4 +25,4 @@
|
||||
"typescript": "catalog:",
|
||||
"@typescript/native-preview": "catalog:"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user