Compare commits

..

105 Commits

Author SHA1 Message Date
Adam b5ebec82c4 fix(app): windows server spawning error 2026-02-12 13:44:38 -06:00
Aiden Cline d1ee4c8dca test: add more test cases for project.test.ts (#13355) 2026-02-12 18:46:44 +00:00
opencode ac018e3a35 release: v1.1.63 2026-02-12 18:46:38 +00:00
Dax Raad e6e9c15d34 improve codex model list 2026-02-12 18:15:30 +00:00
opencode aaee5fb680 release: v1.1.62 2026-02-12 18:15:24 +00:00
Adam ff0abacf4b fix(app): project icons unloading 2026-02-12 11:50:17 -06:00
Rasheed 0771e3a8be fix(app): preserve undo history for plain-text paste (#13351) 2026-02-12 17:27:53 +00:00
Adam da952135ca chore(app): refactor for better solidjs hygiene (#13344) 2026-02-12 11:26:19 -06:00
Dax Raad 789705ea96 ignore: document test fixtures for agents 2026-02-12 12:10:21 -05:00
Ryan Vogel ba54cee55e feat(tool): return image attachments from webfetch (#13331) 2026-02-12 12:09:29 -05:00
opencode-agent[bot] 847e06f9e1 chore: update nix node_modules hashes 2026-02-12 17:09:09 +00:00
Aiden Cline 2db618dea3 fix: downgrade bun to 1.3.5 (#13347) 2026-02-12 16:59:08 +00:00
Dylan Fiedler ecab692ca1 fix(docs): correct format attribute in StructuredOutputs (#13340) 2026-02-12 10:55:43 -06:00
Frank 59a323e9a8 wip: zen 2026-02-12 11:11:25 -05:00
Frank 658bf6fa58 zen: minimax m2.5 2026-02-12 11:04:09 -05:00
Adam a82ca86008 fix(app): more defensive code component 2026-02-12 10:00:58 -06:00
Adam ed472d8a67 fix(app): more defensive session context metrics 2026-02-12 10:00:58 -06:00
Adam ff4414bb15 chore: refactor packages/app files (#13236)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
2026-02-12 09:49:14 -06:00
Dax Raad 56ad2db020 core: expose tool arguments in shell hook for plugin visibility 2026-02-12 09:54:47 -05:00
Frank ae811ad8d2 wip: zen 2026-02-12 14:45:52 +00:00
opencode-agent[bot] 85df106713 chore: generate 2026-02-12 14:45:52 +00:00
opencode 892bb75265 release: v1.1.61 2026-02-12 14:45:45 +00:00
Dax Raad a115565054 core: allow model configurations without npm/api provider details
Makes npm and api fields optional in the provider schema so model definitions

can be more flexible when provider package details aren't needed.
2026-02-12 09:26:28 -05:00
Frank d82d22b2d7 wip: zen 2026-02-12 09:17:49 -05:00
Ryan Vogel d723147083 feat: update to not post comment on workflows when no duplicates found (#13238) 2026-02-12 09:13:38 -05:00
opencode-agent[bot] 9f9f0fb8eb chore: update nix node_modules hashes 2026-02-12 13:36:53 +00:00
Adam ecb274273a wip(ui): diff virtualization (#12693) 2026-02-12 07:25:58 -06:00
Adam 5f421883a8 chore: style loading screen 2026-02-12 07:16:30 -06:00
Brendan Allan fa97475ee8 ci: move test-sigining policy 2026-02-12 18:50:00 +08:00
Brendan Allan 0eaeb4588e Testing SignPath Integration (#13308) 2026-02-12 18:46:56 +08:00
Brendan Allan 1413d77b1f desktop: sqlite migration progress bar (#13294) 2026-02-12 09:44:06 +00:00
Aiden Cline 624dd94b5d tweak: tool outputs to be more llm friendly (#13269) 2026-02-12 00:33:18 -06:00
Frank d86f24b6b3 zen: return cost 2026-02-12 01:08:02 -05:00
opencode 03de51bd3c release: v1.1.60 2026-02-12 05:58:24 +00:00
Luke Parker 8f9742d988 fix(win32): use ffi to get around bun raw input/ctrl+c issues (#13052) 2026-02-12 15:39:31 +10:00
opencode-agent[bot] f6e7aefa72 chore: generate 2026-02-12 04:55:00 +00:00
Kyle Mistele e269788a8f feat: support claude agent SDK-style structured outputs in the OpenCode SDK (#8161)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Dax Raad <d@ironbay.co>
2026-02-12 04:54:05 +00:00
opencode-agent[bot] 66780195dc chore: generate 2026-02-12 04:11:57 +00:00
Dax Raad ad2087094d support custom api url per model 2026-02-11 23:10:47 -05:00
github-actions[bot] 5bdf1c4b96 Update VOUCHED list
https://github.com/anomalyco/opencode/issues/13076#issuecomment-3888412535
2026-02-12 03:05:00 +00:00
Kit Langton 135f8ffb2a feat(tui): add toggle to hide session header (#13244) 2026-02-12 02:21:42 +00:00
dpuyosa bf5a01edd9 feat(opencode): Venice Add automatic variant generation for Venice models (#12106) 2026-02-11 20:21:29 -06:00
Adam 81ca2df6ad fix(app): guard randomUUID in insecure browser contexts (#13237)
Co-authored-by: Selim <31136147+selimerunkut@users.noreply.github.com>
2026-02-12 01:05:15 +00:00
Adam aea68c386a fix(docs): locale translations for nav elements and headings 2026-02-11 18:51:50 -06:00
Adam 8eea53a41e docs(ar): second-pass localization cleanup 2026-02-11 18:51:50 -06:00
Aiden Cline 3befd0c6c5 tweak: use promise all for mcp listTools calls (#13229) 2026-02-12 00:19:53 +00:00
opencode-agent[bot] 8577eb8ec9 chore: update nix node_modules hashes 2026-02-11 23:39:24 +00:00
Luke Parker c856f875a1 chore: upgrade bun to 1.3.9 (#13223) 2026-02-12 09:27:58 +10:00
opencode-agent[bot] 264dd213f9 chore: update nix node_modules hashes 2026-02-11 23:22:01 +00:00
Sebastian 125727d09c upgrade opentui to 0.1.79 (#13036) 2026-02-12 00:13:11 +01:00
Aiden Cline 8c7b35ad05 tweak: compaction check (#13214) 2026-02-11 21:43:04 +00:00
github-actions[bot] e2a33f75e1 Update VOUCHED list
https://github.com/anomalyco/opencode/issues/13204#issuecomment-3887062726
2026-02-11 20:40:39 +00:00
Aiden Cline 006d673ed2 tweak: make read tool offset 1 indexed instead of 0 to avoid confusion that could be caused by line #s being 1 based (#13198) 2026-02-11 14:02:30 -06:00
Aiden Cline 6b4d617df0 feat: adjust read tool so that it can handle dirs too (#13090) 2026-02-11 13:23:00 -06:00
Dax Raad e3471526f4 add square logo variants to brand page 2026-02-11 13:47:54 -05:00
Adam 6b30e0b752 chore: update docs sync workflow 2026-02-11 11:47:32 -06:00
Adam fbabce1125 fix(app): translations 2026-02-11 11:03:35 -06:00
opencode-agent[bot] 8f56ed5b85 chore: generate 2026-02-11 17:00:20 +00:00
Filip 81b5a6a08b fix(app):workspace reset (#13170)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-02-11 10:59:09 -06:00
opencode-agent[bot] 94cb6390aa chore: generate 2026-02-11 15:37:06 +00:00
opencode 42bea5d297 release: v1.1.59 2026-02-11 15:37:00 +00:00
Adam f252e3234c fix(app): translations 2026-02-11 09:19:16 -06:00
Filip eef3ae3e1f Fix/reverception (#13166)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2026-02-11 09:18:44 -06:00
Adam fc88dde63f test(app): more e2e tests (#13162) 2026-02-11 15:12:28 +00:00
Adam 4619e9d183 fix(app): sidebar remount 2026-02-11 15:12:28 +00:00
opencode 4dc363f306 release: v1.1.58 2026-02-11 15:12:01 +00:00
Adam 2e8082dd21 Revert "feat(desktop): add WSL backend mode (#12914)"
This reverts commit 213a87234d.
2026-02-11 08:51:41 -06:00
Adam a52fe28246 fix(app): notifications on child sessions 2026-02-11 08:47:27 -06:00
Adam 8c5ba8aeb0 fix(app): terminal PTY buffer carryover 2026-02-11 08:47:26 -06:00
Adam 50330820c0 fix(console): translations 2026-02-11 08:47:26 -06:00
Adam 7222fc0ba0 fix(app): terminal resize 2026-02-11 08:47:26 -06:00
Kit Langton 17bdb5d56a fix(tui): dismiss dialogs with ctrl+c (#12884) 2026-02-11 09:03:59 -05:00
Kit Langton 7a463cd193 fix(tui): keep /share available to copy existing link (#12532) 2026-02-11 08:58:24 -05:00
Kit Langton 352a54c698 feat(prompt): mode-specific input placeholders (#12388) 2026-02-11 08:57:59 -05:00
Kit Langton 93957da2c9 fix(tui): prevent home wordmark corruption in height-constrained terminals (#13069) 2026-02-11 08:52:14 -05:00
opencode edcfd562af release: v1.1.57 2026-02-11 13:45:34 +00:00
Adam ef5ec5dc28 fix(app): terminal copy/paste 2026-02-11 07:19:33 -06:00
Adam c426cb0f14 fix(app): copy path button styles 2026-02-11 07:19:33 -06:00
Shintaro Jokagi dd1862cc2b fix(web): prevent language select label truncation (#13100) 2026-02-11 06:43:48 -06:00
Brendan Allan a25b2af05a desktop: use tracing for logging (#13135) 2026-02-11 11:36:27 +00:00
Jack 8bfd6fdba2 fix: encode non-ASCII directory paths in v1 SDK HTTP headers (#13131) 2026-02-11 05:33:25 -06:00
Filip cf7a1b8d80 feat(desktop): enhance Windows app resolution and UI loading states (#13084) 2026-02-11 04:40:52 -06:00
opencode-agent[bot] 5ba4c0e024 chore: generate 2026-02-11 10:38:52 +00:00
Jun 567e094e6c docs(ko): improve translations for intro, cli, and commands (#13094) 2026-02-11 04:37:52 -06:00
webwww123 b523998329 fix(docs): avoid footer language selector truncation (#13124) 2026-02-11 04:33:50 -06:00
Adam 7e1247c420 fix(desktop): server spawn resilience (#13028)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-02-11 09:44:26 +00:00
Brendan Allan 783888131e fix(desktop): read wayland preference from store (#13081) 2026-02-11 08:10:24 +00:00
Brendan Allan 213a87234d feat(desktop): add WSL backend mode (#12914) 2026-02-11 07:49:48 +00:00
Aiden Cline d98bd4bd52 fix: add additional context overflow cases, remove overcorrecting ones (#13077) 2026-02-10 23:30:29 -06:00
Frank 22125d1347 wip: zen 2026-02-11 00:05:04 -05:00
Ariane Emory 8c120f2fab docs: remove 'Migrating to 1.0' documentation section (#13076) 2026-02-10 22:52:02 -06:00
opencode-agent[bot] c6ec2f47ef chore: generate 2026-02-11 01:56:08 +00:00
Aiden Cline 0fd6f365be fix(core): ensure compaction is more reliable, add reserve token buffer to ensure that input window has enough room to compact (#12924)
Co-authored-by: James Lal <james@littlebearlabs.io>
2026-02-10 19:55:22 -06:00
Aiden Cline 60bdb6e9ba tweak: /review prompt to look for behavior changes more explicitly (#13049) 2026-02-10 19:44:42 -06:00
Dax 6e9cd576ea fix(tui): default session sidebar to auto (#13046) 2026-02-11 01:26:01 +00:00
OpeOginni 53ec15a56a fix(tui): improve amazon-bedrock check to include container credentials (#13037) 2026-02-10 18:51:49 -06:00
github-actions[bot] a90b62267f Update VOUCHED list
https://github.com/anomalyco/opencode/issues/12841#issuecomment-3881500535
2026-02-11 00:37:36 +00:00
Frank 24556331c8 wip: zen 2026-02-10 17:56:10 -05:00
Frank 39145b99e8 wip: zen 2026-02-10 17:44:00 -05:00
Frank 0afa6e03a8 wip: zen 2026-02-10 17:36:32 -05:00
Frank 7a3c775dc1 wip: zen 2026-02-10 17:24:03 -05:00
Frank 3ea58bb790 wip: zen 2026-02-10 17:15:01 -05:00
Adam 50c705cd2d fix(docs): locale translations 2026-02-10 22:12:32 +00:00
Frank 3894c217cc wip: zen 2026-02-10 22:12:32 +00:00
Adam 66c2bb8f37 chore: update website stats 2026-02-10 22:12:32 +00:00
806 changed files with 32338 additions and 24975 deletions
+3
View File
@@ -8,6 +8,8 @@
# - Denounce with minus prefix: -username or -platform:username.
# - Optional details after a space following the handle.
adamdotdevin
ariane-emory
-florianleibert
fwang
iamdavidhill
jayair
@@ -15,4 +17,5 @@ kitlangton
kommander
r44vc0rp
rekram1-node
-spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr
+7 -4
View File
@@ -64,10 +64,13 @@ jobs:
Requirements:
1. Update all relevant locale docs under packages/web/src/content/docs/<locale>/ so they reflect these English page changes.
2. Preserve frontmatter keys, internal links, code blocks, and existing locale-specific metadata unless the English change requires an update.
3. Keep locale docs structure aligned with their corresponding English pages.
4. Do not modify English source docs in packages/web/src/content/docs/*.mdx.
5. If no locale updates are needed, make no changes.
2. You MUST use the Task tool for translation work and launch subagents with subagent_type `translator` (defined in .opencode/agent/translator.md).
3. Do not translate directly in the primary agent. Use translator subagent output as the source for locale text updates.
4. Run translator subagent Task calls in parallel whenever file/locale translation work is independent.
5. Preserve frontmatter keys, internal links, code blocks, and existing locale-specific metadata unless the English change requires an update.
6. Keep locale docs structure aligned with their corresponding English pages.
7. Do not modify English source docs in packages/web/src/content/docs/*.mdx.
8. If no locale updates are needed, make no changes.
- name: Commit and push locale docs updates
if: steps.changes.outputs.has_changes == 'true'
+3 -1
View File
@@ -60,9 +60,11 @@ jobs:
run: |
COMMENT=$(bun script/duplicate-pr.ts -f pr_info.txt "Check the attached file for PR details and search for duplicates")
gh pr comment "$PR_NUMBER" --body "_The following comment was made by an LLM, it may be inaccurate:_
if [ "$COMMENT" != "No duplicate PRs found" ]; then
gh pr comment "$PR_NUMBER" --body "_The following comment was made by an LLM, it may be inaccurate:_
$COMMENT"
fi
add-contributor-label:
runs-on: ubuntu-latest
+54
View File
@@ -0,0 +1,54 @@
name: sign-cli
on:
push:
branches:
- brendan/desktop-signpath
workflow_dispatch:
permissions:
contents: read
actions: read
jobs:
sign-cli:
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@v3
with:
fetch-tags: true
- uses: ./.github/actions/setup-bun
- name: Build
run: |
./packages/opencode/script/build.ts
- name: Upload unsigned Windows CLI
id: upload_unsigned_windows_cli
uses: actions/upload-artifact@v4
with:
name: unsigned-opencode-windows-cli
path: packages/opencode/dist/opencode-windows-x64/bin/opencode.exe
if-no-files-found: error
- name: Submit SignPath signing request
id: submit_signpath_signing_request
uses: signpath/github-action-submit-signing-request@v1
with:
api-token: ${{ secrets.SIGNPATH_API_KEY }}
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }}
artifact-configuration-slug: ${{ secrets.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
github-artifact-id: ${{ steps.upload_unsigned_windows_cli.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: signed-opencode-cli
- name: Upload signed Windows CLI
uses: actions/upload-artifact@v4
with:
name: signed-opencode-windows-cli
path: signed-opencode-cli/*.exe
if-no-files-found: error
+3
View File
@@ -32,6 +32,9 @@ description: Use this when you are working on file operations like reading, writ
- Decode tool stderr with `Bun.readableStreamToText`.
- For large writes, use `Bun.write(Bun.file(path), text)`.
NOTE: Bun.file(...).exists() will return `false` if the value is a directory.
Use Filesystem.exists(...) instead if path can be file or directory
## Quick checklist
- Use Bun APIs first.
@@ -0,0 +1,7 @@
github-policies:
runners:
allowed_groups:
- "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt"
build:
disallow_reruns: false
branch_rulesets:
+34 -52
View File
@@ -23,7 +23,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.56",
"version": "1.1.63",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -73,7 +73,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.56",
"version": "1.1.63",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -107,7 +107,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.1.56",
"version": "1.1.63",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -134,7 +134,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.1.56",
"version": "1.1.63",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -158,7 +158,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.1.56",
"version": "1.1.63",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -182,7 +182,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.1.56",
"version": "1.1.63",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -215,7 +215,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.56",
"version": "1.1.63",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -244,7 +244,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.56",
"version": "1.1.63",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -260,7 +260,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.56",
"version": "1.1.63",
"bin": {
"opencode": "./bin/opencode",
},
@@ -301,8 +301,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.77",
"@opentui/solid": "0.1.77",
"@opentui/core": "0.1.79",
"@opentui/solid": "0.1.79",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -366,7 +366,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.56",
"version": "1.1.63",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -386,7 +386,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.56",
"version": "1.1.63",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -397,7 +397,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.56",
"version": "1.1.63",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -410,7 +410,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.56",
"version": "1.1.63",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -452,7 +452,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.56",
"version": "1.1.63",
"dependencies": {
"zod": "catalog:",
},
@@ -463,7 +463,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.56",
"version": "1.1.63",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -513,7 +513,7 @@
"@kobalte/core": "0.13.11",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.0.2",
"@pierre/diffs": "1.1.0-beta.13",
"@playwright/test": "1.51.0",
"@solid-primitives/storage": "4.3.3",
"@solidjs/meta": "0.29.4",
@@ -522,7 +522,7 @@
"@tailwindcss/vite": "4.1.11",
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.8",
"@types/bun": "1.3.5",
"@types/luxon": "3.7.1",
"@types/node": "22.13.9",
"@types/semver": "7.7.1",
@@ -1279,21 +1279,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.77", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.77", "@opentui/core-darwin-x64": "0.1.77", "@opentui/core-linux-arm64": "0.1.77", "@opentui/core-linux-x64": "0.1.77", "@opentui/core-win32-arm64": "0.1.77", "@opentui/core-win32-x64": "0.1.77", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-lE3kabm6jdqK3AuBq+O0zZrXdxt6ulmibTc57sf+AsPny6cmwYHnWI4wD6hcreFiYoQVNVvdiJchVgPtowMlEg=="],
"@opentui/core": ["@opentui/core@0.1.79", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.79", "@opentui/core-darwin-x64": "0.1.79", "@opentui/core-linux-arm64": "0.1.79", "@opentui/core-linux-x64": "0.1.79", "@opentui/core-win32-arm64": "0.1.79", "@opentui/core-win32-x64": "0.1.79", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-job/t09w8A/aHb/WuaVbimu5fIffyN+PCuVO5cYhXEg/NkOkC/WdFi80B8bwncR/DBPyLAh6oJ3EG86grOVo5g=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.77", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SNqmygCMEsPCW7xWjzCZ5caBf36xaprwVdAnFijGDOuIzLA4iaDa6um8cj3TJh7awenN3NTRsuRc7OuH42UH+g=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.79", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kgsGniV+DM5G1P3GideyJhvfnthNKcVCAm2mPTIr9InQ3L0gS/Feh7zgwOS/jxDvdlQbOWGKMk2Z3JApeC1MLw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.77", "", { "os": "darwin", "cpu": "x64" }, "sha512-/8fsa03swEHTQt/9NrGm98kemlU+VuTURI/OFZiH53vPDRrOYIYoa4Jyga/H7ZMcG+iFhkq97zIe+0Kw95LGmA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.79", "", { "os": "darwin", "cpu": "x64" }, "sha512-OpyAmFqAAKQ2CeFmf/oLWcNksmP6Ryx/3R5dbKXThOudMCeQvfvInJTRbc2jTn9VFpf+Qj4BgHkJg1h90tf/EA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.77", "", { "os": "linux", "cpu": "arm64" }, "sha512-QfUXZJPc69OvqoMu+AlLgjqXrwu4IeqcBuUWYMuH8nPTeLsVUc3CBbXdV2lv9UDxWzxzrxdS4ALPaxvmEv9lsQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.79", "", { "os": "linux", "cpu": "arm64" }, "sha512-DCa5YaknS4bWhFt8TMEGH+qmTinyzuY8hoZbO4crtWXAxofPP7Pas76Cwxlvis/PyLffA+pPxAl1l5sUZpsvqw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.77", "", { "os": "linux", "cpu": "x64" }, "sha512-Kmfx0yUKnPj67AoXYIgL7qQo0QVsUG5Iw8aRtv6XFzXqa5SzBPhaKkKZ9yHPjOmTalZquUs+9zcCRNKpYYuL7A=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.79", "", { "os": "linux", "cpu": "x64" }, "sha512-V6xjvFfHh3NGvsuuDae1KHPRZXHMEE8XL0A/GM6v4I4OCC23kDmkK60Vn6OptQwAzwwbz0X0IX+Ut/GQU9qGgA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.77", "", { "os": "win32", "cpu": "arm64" }, "sha512-HGTscPXc7gdd23Nh1DbzUNjog1I+5IZp95XPtLftGTpjrWs60VcetXcyJqK2rQcXNxewJK5yDyaa5QyMjfEhCQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.79", "", { "os": "win32", "cpu": "arm64" }, "sha512-sPRKnVzOdT5szI59tte7pxwwkYA+07EQN+6miFAvkFuiLmRUngONUD8HVjL7nCnxcPFqxaU4Rvl1y40ST86g8g=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.77", "", { "os": "win32", "cpu": "x64" }, "sha512-c7GijsbvVgnlzd2murIbwuwrGbcv76KdUw6WlVv7a0vex50z6xJCpv1keGzpe0QfxrZ/6fFEFX7JnwGLno0wjA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.79", "", { "os": "win32", "cpu": "x64" }, "sha512-vmQcFTvKf9fqajnDtgU6/uAsiTGwx8//khqHVBmiTEXUsiT792Ki9l8sgNughbuldqG5iZOiF6IaAWU1H67UpA=="],
"@opentui/solid": ["@opentui/solid@0.1.77", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.77", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-JY+hUbXVV+XCk6bC8dvcwawWCEmC3Gid6GDs23AJWBgHZ3TU2kRKrgwTdltm45DOq2cZXrYCt690/yE8bP+Gxg=="],
"@opentui/solid": ["@opentui/solid@0.1.79", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.79", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-c5+0jexKxb8GwRDDkQ/U6isZZqClAzHccXmYiLYmSnqdoQQp2lIGHLartL+K8lfIQrsKClzP2ZHumN6nexRfRg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1409,7 +1409,7 @@
"@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
"@pierre/diffs": ["@pierre/diffs@1.0.2", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/transformers": "3.19.0", "diff": "8.0.2", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.19.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-RkFSDD5X/U+8QjyilPViYGJfmJNWXR17zTL8zw48+DcVC1Ujbh6I1edyuRnFfgRzpft05x2DSCkz2cjoIAxPvQ=="],
"@pierre/diffs": ["@pierre/diffs@1.1.0-beta.13", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-D35rxDu5V7XHX5aVGU6PF12GhscL+I+9QYgxK/i3h0d2XSirAxDdVNm49aYwlOhgmdvL0NbS1IHxPswVB5yJvw=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
@@ -1853,7 +1853,7 @@
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
@@ -2181,7 +2181,7 @@
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
@@ -4387,13 +4387,9 @@
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
"@pierre/diffs/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
"@pierre/diffs/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-ZfWJNm2VMhKkQIKT9qXbs76RRcT0SF/CAvEz0+RkpUDAoDaCx0uFdCGzSRiD9gSlhm6AHkjdieOBJMaO2eC1rQ=="],
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.19.0", "", { "dependencies": { "@shikijs/core": "3.19.0", "@shikijs/types": "3.19.0" } }, "sha512-e6vwrsyw+wx4OkcrDbL+FVCxwx8jgKiCoXzakVur++mIWVcgpzIi8vxf4/b4dVTYrV/nUx5RjinMf4tq8YV8Fw=="],
"@pierre/diffs/shiki": ["shiki@3.19.0", "", { "dependencies": { "@shikijs/core": "3.19.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/engine-oniguruma": "3.19.0", "@shikijs/langs": "3.19.0", "@shikijs/themes": "3.19.0", "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-77VJr3OR/VUZzPiStyRhADmO2jApMM0V2b1qf0RpfWya8Zr1PeZev5AEpPGAAKWdiYUtcZGBE4F5QvJml1PvWA=="],
"@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
@@ -4973,23 +4969,9 @@
"@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@pierre/diffs/@shikijs/core/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@pierre/diffs/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
"@pierre/diffs/@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.19.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="],
"@pierre/diffs/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-L7SrRibU7ZoYi1/TrZsJOFAnnHyLTE1SwHG1yNWjZIVCqjOEmCSuK2ZO9thnRbJG6TOkPp+Z963JmpCNw5nzvA=="],
"@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.19.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="],
"@pierre/diffs/shiki/@shikijs/core": ["@shikijs/core@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-L7SrRibU7ZoYi1/TrZsJOFAnnHyLTE1SwHG1yNWjZIVCqjOEmCSuK2ZO9thnRbJG6TOkPp+Z963JmpCNw5nzvA=="],
"@pierre/diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1hRxtYIJfJSZeM5ivbUXv9hcJP3PWRo5prG/V2sWwiubUKTa+7P62d2qxCW8jiVFX4pgRHhnHNp+qeR7Xl+6kg=="],
"@pierre/diffs/shiki/@shikijs/langs": ["@shikijs/langs@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0" } }, "sha512-dBMFzzg1QiXqCVQ5ONc0z2ebyoi5BKz+MtfByLm0o5/nbUu3Iz8uaTCa5uzGiscQKm7lVShfZHU1+OG3t5hgwg=="],
"@pierre/diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0" } }, "sha512-H36qw+oh91Y0s6OlFfdSuQ0Ld+5CgB/VE6gNPK+Hk4VRbVG/XQgkjnt4KzfnnoO6tZPtKJKHPjwebOCfjd6F8A=="],
"@pierre/diffs/shiki/@shikijs/types": ["@shikijs/types@3.19.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="],
"@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
@@ -1,15 +0,0 @@
{
"keep": {
"days": true,
"amount": 14
},
"auditLog": "/home/thdxr/dev/projects/sst/opencode/logs/.2c5480b3b2480f80fa29b850af461dce619c0b2f-audit.json",
"files": [
{
"date": 1759827172859,
"name": "/home/thdxr/dev/projects/sst/opencode/logs/mcp-puppeteer-2025-10-07.log",
"hash": "a3d98b26edd793411b968a0d24cfeee8332138e282023c3b83ec169d55c67f16"
}
],
"hashType": "sha256"
}
-48
View File
@@ -1,48 +0,0 @@
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:52.879"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:52.880"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:56.191"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:56.192"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:59.267"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:59.268"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:20.276"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:20.277"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:30.838"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:30.839"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:42.452"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:42.452"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:46.499"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:46.500"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:02.295"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:02.295"}
{"arguments":{"url":"https://google.com"},"level":"debug","message":"Tool call received","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:37.150","tool":"puppeteer_navigate"}
{"0":"n","1":"p","2":"x","level":"info","message":"Launching browser with config:","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:37.150"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:55:08.488"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:55:08.489"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:11.815"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:11.816"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:21.934"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:21.935"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:32.544"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:32.544"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:41.154"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:41.155"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:55.426"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:55.427"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:15.715"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:15.716"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:25.063"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:25.064"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:48.567"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:48.568"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:25:08.937"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:25:08.938"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:37.120"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:37.121"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:52.490"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:52.491"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:39:25.524"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:39:25.525"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:40:57.126"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:40:57.127"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:42:24.175"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:42:24.176"}
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-cvRBvHRuunNjF07c4GVHl5rRgoTn1qfI/HdJWtOV63M=",
"aarch64-linux": "sha256-DJUI4pMZ7wQTnyOiuDHALmZz7FZtrTbzRzCuNOShmWE=",
"aarch64-darwin": "sha256-JnkqDwuC7lNsjafV+jOGfvs8K1xC8rk5CTOW+spjiCA=",
"x86_64-darwin": "sha256-GBeTqq2vDn/mXplYNglrAT2xajjFVzB4ATHnMS0j7z4="
"x86_64-linux": "sha256-saYZlUTkBfg9vp5J1CrJUM1PBXK4xKwyz28RKlT0JWo=",
"aarch64-linux": "sha256-qoiX2CpOD+HSI+eLh3I84TTPdhWdG6MzfkDAXE6ldPo=",
"aarch64-darwin": "sha256-LbAvdaOBuftBoHvQPFwJGr0smg8vH4wNHS6BYdyXdDs=",
"x86_64-darwin": "sha256-bv5qb9Fi8SyrgZFhcdlvYNc4bjyvdyHY3YgUpmkEH2U="
}
}
+3 -3
View File
@@ -4,7 +4,7 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.8",
"packageManager": "bun@1.3.5",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri dev",
@@ -23,7 +23,7 @@
"packages/slack"
],
"catalog": {
"@types/bun": "1.3.8",
"@types/bun": "1.3.5",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"ulid": "3.0.1",
@@ -35,7 +35,7 @@
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.0.2",
"@pierre/diffs": "1.1.0-beta.13",
"@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
+83 -1
View File
@@ -1,6 +1,7 @@
import { test, expect } from "../fixtures"
import { openSidebar, withSession } from "../actions"
import { defocus, openSidebar, withSession } from "../actions"
import { promptSelector } from "../selectors"
import { modKey } from "../utils"
test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
await page.setViewportSize({ width: 1400, height: 800 })
@@ -40,3 +41,84 @@ test("titlebar back/forward navigates between sessions", async ({ page, slug, sd
})
})
})
test("titlebar forward is cleared after branching history from sidebar", async ({ page, slug, sdk, gotoSession }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const stamp = Date.now()
await withSession(sdk, `e2e titlebar history a ${stamp}`, async (a) => {
await withSession(sdk, `e2e titlebar history b ${stamp}`, async (b) => {
await withSession(sdk, `e2e titlebar history c ${stamp}`, async (c) => {
await gotoSession(a.id)
await openSidebar(page)
const second = page.locator(`[data-session-id="${b.id}"] a`).first()
await expect(second).toBeVisible()
await second.scrollIntoViewIfNeeded()
await second.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
const back = page.getByRole("button", { name: "Back" })
const forward = page.getByRole("button", { name: "Forward" })
await expect(back).toBeVisible()
await expect(back).toBeEnabled()
await back.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${a.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await openSidebar(page)
const third = page.locator(`[data-session-id="${c.id}"] a`).first()
await expect(third).toBeVisible()
await third.scrollIntoViewIfNeeded()
await third.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await expect(forward).toBeVisible()
await expect(forward).toBeDisabled()
})
})
})
})
test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, gotoSession }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const stamp = Date.now()
await withSession(sdk, `e2e titlebar shortcuts 1 ${stamp}`, async (one) => {
await withSession(sdk, `e2e titlebar shortcuts 2 ${stamp}`, async (two) => {
await gotoSession(one.id)
await openSidebar(page)
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(link).toBeVisible()
await link.scrollIntoViewIfNeeded()
await link.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await defocus(page)
await page.keyboard.press(`${modKey}+[`)
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await defocus(page)
await page.keyboard.press(`${modKey}+]`)
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
})
})
})
+16 -3
View File
@@ -1,15 +1,28 @@
import { test, expect } from "../fixtures"
import { openPalette, clickListItem } from "../actions"
import { promptSelector } from "../selectors"
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openPalette(page)
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
const command = page.locator('[data-slash-id="file.open"]').first()
await expect(command).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
await clickListItem(dialog, { keyStartsWith: "file:" })
const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
await expect(item).toBeVisible({ timeout: 30_000 })
await item.click()
await expect(dialog).toHaveCount(0)
+29 -17
View File
@@ -1,37 +1,49 @@
import { test, expect } from "../fixtures"
test.skip("file tree can expand folders and open a file", async ({ page, gotoSession }) => {
test("file tree can expand folders and open a file", async ({ page, gotoSession }) => {
await gotoSession()
const toggle = page.getByRole("button", { name: "Toggle file tree" })
const treeTabs = page.locator('[data-component="tabs"][data-variant="pill"][data-scope="filetree"]')
const panel = page.locator("#file-tree-panel")
const treeTabs = panel.locator('[data-component="tabs"][data-variant="pill"][data-scope="filetree"]')
await expect(toggle).toBeVisible()
if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(panel).toBeVisible()
await expect(treeTabs).toBeVisible()
await treeTabs.locator('[data-slot="tabs-trigger"]').nth(1).click()
const allTab = treeTabs.getByRole("tab", { name: /^all files$/i })
await expect(allTab).toBeVisible()
await allTab.click()
await expect(allTab).toHaveAttribute("aria-selected", "true")
const node = (name: string) => treeTabs.getByRole("button", { name, exact: true })
const tree = treeTabs.locator('[data-slot="tabs-content"]:not([hidden])')
await expect(tree).toBeVisible()
await expect(node("packages")).toBeVisible()
await node("packages").click()
const expand = async (name: string) => {
const folder = tree.getByRole("button", { name, exact: true }).first()
await expect(folder).toBeVisible()
await expect(folder).toHaveAttribute("aria-expanded", /true|false/)
if ((await folder.getAttribute("aria-expanded")) === "false") await folder.click()
await expect(folder).toHaveAttribute("aria-expanded", "true")
}
await expect(node("app")).toBeVisible()
await node("app").click()
await expand("packages")
await expand("app")
await expand("src")
await expand("components")
await expect(node("src")).toBeVisible()
await node("src").click()
await expect(node("components")).toBeVisible()
await node("components").click()
await expect(node("file-tree.tsx")).toBeVisible()
await node("file-tree.tsx").click()
const file = tree.getByRole("button", { name: "file-tree.tsx", exact: true }).first()
await expect(file).toBeVisible()
await file.click()
const tab = page.getByRole("tab", { name: "file-tree.tsx" })
await expect(tab).toBeVisible()
await tab.click()
await expect(tab).toHaveAttribute("aria-selected", "true")
const code = page.locator('[data-component="code"]').first()
await expect(code.getByText("export default function FileTree")).toBeVisible()
await expect(code).toBeVisible()
await expect(code).toContainText("export default function FileTree")
})
+30 -7
View File
@@ -1,18 +1,41 @@
import { test, expect } from "../fixtures"
import { openPalette, clickListItem } from "../actions"
import { promptSelector } from "../selectors"
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
await gotoSession()
const sep = process.platform === "win32" ? "\\" : "/"
const file = ["packages", "app", "package.json"].join(sep)
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
const dialog = await openPalette(page)
const command = page.locator('[data-slash-id="file.open"]').first()
await expect(command).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill(file)
await input.fill("package.json")
await clickListItem(dialog, { text: /packages.*app.*package.json/ })
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
let index = -1
await expect
.poll(
async () => {
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
return index >= 0
},
{ timeout: 30_000 },
)
.toBe(true)
const item = items.nth(index)
await expect(item).toBeVisible()
await item.click()
await expect(dialog).toHaveCount(0)
@@ -22,5 +45,5 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
const code = page.locator('[data-component="code"]').first()
await expect(code).toBeVisible()
await expect(code.getByText("@opencode-ai/app")).toBeVisible()
await expect(code.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
})
@@ -1,6 +1,6 @@
import { test, expect } from "../fixtures"
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem } from "../actions"
import { projectCloseHoverSelector, projectCloseMenuSelector, projectSwitchSelector } from "../selectors"
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions"
import { projectCloseHoverSelector, projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils"
test("can close a project via hover card close button", async ({ page, withProject }) => {
@@ -31,16 +31,15 @@ test("can close a project via hover card close button", async ({ page, withProje
}
})
test("can close a project via project header more options menu", async ({ page, withProject }) => {
test("closing active project navigates to another open project", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherName = other.split("/").pop() ?? other
const otherSlug = dirSlug(other)
try {
await withProject(
async () => {
async ({ slug }) => {
await openSidebar(page)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
@@ -49,21 +48,20 @@ test("can close a project via project header more options menu", async ({ page,
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
const header = page
.locator(".group\\/project")
.filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) })
.first()
await expect(header).toContainText(otherName)
const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first()
await expect(trigger).toHaveCount(1)
await trigger.focus()
await page.keyboard.press("Enter")
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
await expect(menu).toBeVisible({ timeout: 10_000 })
const menu = await openProjectMenu(page, otherSlug)
await clickMenuItem(menu, /^Close$/i, { force: true })
await expect
.poll(() => {
const pathname = new URL(page.url()).pathname
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
if (pathname === "/") return "home"
return ""
})
.toMatch(/^(project|home)$/)
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
await expect(otherButton).toHaveCount(0)
},
{ extra: [other] },
@@ -69,15 +69,19 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await expect(prompt).toBeEditable()
await prompt.click()
await page.keyboard.type(text)
await page.keyboard.press("Enter")
await expect(prompt).toBeFocused()
await prompt.fill(text)
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
await prompt.press("Enter")
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
await expect(page).toHaveURL(new RegExp(`/${slug}/session/[^/?#]+`), { timeout: 30_000 })
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`))
return sessionID
}
+79 -3
View File
@@ -1,5 +1,6 @@
import { base64Decode } from "@opencode-ai/util/encode"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import type { Page } from "@playwright/test"
@@ -14,7 +15,8 @@ import {
openWorkspaceMenu,
setWorkspacesEnabled,
} from "../actions"
import { inlineInputSelector, workspaceItemSelector } from "../selectors"
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
import { createSdk, dirSlug } from "../utils"
function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
@@ -126,6 +128,49 @@ test("can create a workspace", async ({ page, withProject }) => {
})
})
test("non-git projects keep workspace mode disabled", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
const nonGitSlug = dirSlug(nonGit)
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
try {
await withProject(async () => {
await page.goto(`/${nonGitSlug}/session`)
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
const activeDir = base64Decode(slugFromUrl(page.url()))
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
const trigger = page.locator('[data-action="project-menu"]').first()
const hasMenu = await trigger
.isVisible()
.then((x) => x)
.catch(() => false)
if (!hasMenu) return
await trigger.click({ force: true })
const menu = page.locator(dropdownMenuContentSelector).first()
await expect(menu).toBeVisible()
const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first()
await expect(toggle).toBeVisible()
await expect(toggle).toBeDisabled()
await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
})
} finally {
await cleanupTestProject(nonGit)
}
})
test("can rename a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
@@ -214,14 +259,45 @@ test("can delete a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async (project) => {
const { rootSlug, slug } = await setupWorkspaceTest(page, project)
const sdk = createSdk(project.directory)
const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
await expect
.poll(
async () => {
const worktrees = await sdk.worktree
.list()
.then((r) => r.data ?? [])
.catch(() => [] as string[])
return worktrees.includes(directory)
},
{ timeout: 30_000 },
)
.toBe(true)
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Delete$/i, { force: true })
await confirmDialog(page, /^Delete workspace$/i)
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
await expect
.poll(
async () => {
const worktrees = await sdk.worktree
.list()
.then((r) => r.data ?? [])
.catch(() => [] as string[])
return worktrees.includes(directory)
},
{ timeout: 60_000 },
)
.toBe(false)
await project.gotoSession()
await openSidebar(page)
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
})
})
+79 -24
View File
@@ -1,40 +1,95 @@
import { test, expect } from "../fixtures"
import type { Page } from "@playwright/test"
import { promptSelector } from "../selectors"
import { withSession } from "../actions"
function contextButton(page: Page) {
return page
.locator('[data-component="button"]')
.filter({ has: page.locator('[data-component="progress-circle"]').first() })
.first()
}
async function seedContextSession(input: { sessionID: string; sdk: Parameters<typeof withSession>[0] }) {
await input.sdk.session.promptAsync({
sessionID: input.sessionID,
noReply: true,
parts: [
{
type: "text",
text: "seed context",
},
],
})
await expect
.poll(async () => {
const messages = await input.sdk.session
.messages({ sessionID: input.sessionID, limit: 1 })
.then((r) => r.data ?? [])
return messages.length
})
.toBeGreaterThan(0)
}
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
const title = `e2e smoke context ${Date.now()}`
await withSession(sdk, title, async (session) => {
await sdk.session.promptAsync({
sessionID: session.id,
noReply: true,
parts: [
{
type: "text",
text: "seed context",
},
],
})
await expect
.poll(async () => {
const messages = await sdk.session.messages({ sessionID: session.id, limit: 1 }).then((r) => r.data ?? [])
return messages.length
})
.toBeGreaterThan(0)
await seedContextSession({ sessionID: session.id, sdk })
await gotoSession(session.id)
const contextButton = page
.locator('[data-component="button"]')
.filter({ has: page.locator('[data-component="progress-circle"]').first() })
.first()
await expect(contextButton).toBeVisible()
await contextButton.click()
const trigger = contextButton(page)
await expect(trigger).toBeVisible()
await trigger.click()
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
})
})
test("context panel can be closed from the context tab close action", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, `e2e context toggle ${Date.now()}`, async (session) => {
await seedContextSession({ sessionID: session.id, sdk })
await gotoSession(session.id)
await page.locator(promptSelector).click()
const trigger = contextButton(page)
await expect(trigger).toBeVisible()
await trigger.click()
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
const context = tabs.getByRole("tab", { name: "Context" })
await expect(context).toBeVisible()
await page.getByRole("button", { name: "Close tab" }).first().click()
await expect(context).toHaveCount(0)
})
})
test("context panel can open file picker from context actions", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, `e2e context tabs ${Date.now()}`, async (session) => {
await seedContextSession({ sessionID: session.id, sdk })
await gotoSession(session.id)
await page.locator(promptSelector).click()
const trigger = contextButton(page)
await expect(trigger).toBeVisible()
await trigger.click()
await expect(page.getByRole("tab", { name: "Context" })).toBeVisible()
await page.getByRole("button", { name: "Open file" }).first().click()
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})
})
-3
View File
@@ -44,9 +44,6 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
)
.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)
@@ -10,39 +10,42 @@ async function seedConversation(input: {
sessionID: string
token: string
}) {
const messages = async () =>
await input.sdk.session.messages({ sessionID: input.sessionID, limit: 100 }).then((r) => r.data ?? [])
const seeded = await messages()
const userIDs = new Set(seeded.filter((m) => m.info.role === "user").map((m) => m.info.id))
const prompt = input.page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.click()
await input.page.keyboard.type(`Reply with exactly: ${input.token}`)
await input.page.keyboard.press("Enter")
await input.sdk.session.promptAsync({
sessionID: input.sessionID,
noReply: true,
parts: [{ type: "text", text: input.token }],
})
let userMessageID: string | undefined
await expect
.poll(
async () => {
const messages = await input.sdk.session
.messages({ sessionID: input.sessionID, limit: 50 })
.then((r) => r.data ?? [])
const users = messages.filter((m) => m.info.role === "user")
const users = (await messages()).filter(
(m) =>
!userIDs.has(m.info.id) &&
m.info.role === "user" &&
m.parts.filter((p) => p.type === "text").some((p) => p.text.includes(input.token)),
)
if (users.length === 0) return false
const user = users.reduce((acc, item) => (item.info.id > acc.info.id ? item : acc))
const user = users[users.length - 1]
if (!user) return false
userMessageID = user.info.id
const assistantText = messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
return assistantText.includes(input.token)
return true
},
{ timeout: 90_000 },
{ timeout: 90_000, intervals: [250, 500, 1_000] },
)
.toBe(true)
if (!userMessageID) throw new Error("Expected a user message id")
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`).first()).toBeVisible({ timeout: 30_000 })
return { prompt, userMessageID }
}
@@ -124,3 +127,107 @@ test("slash redo clears revert and restores latest state", async ({ page, withPr
})
})
})
test("slash undo/redo traverses multi-step revert stack", async ({ page, withProject }) => {
test.setTimeout(120_000)
const firstToken = `undo_redo_first_${Date.now()}`
const secondToken = `undo_redo_second_${Date.now()}`
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
await project.gotoSession(session.id)
const first = await seedConversation({
page,
sdk,
sessionID: session.id,
token: firstToken,
})
const second = await seedConversation({
page,
sdk,
sessionID: session.id,
token: secondToken,
})
expect(first.userMessageID).not.toBe(second.userMessageID)
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
await expect(firstMessage.first()).toBeVisible()
await expect(secondMessage.first()).toBeVisible()
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/undo")
const undo = page.locator('[data-slash-id="session.undo"]').first()
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(second.userMessageID)
await expect(firstMessage.first()).toBeVisible()
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/undo")
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(first.userMessageID)
await expect(firstMessage).toHaveCount(0)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/redo")
const redo = page.locator('[data-slash-id="session.redo"]').first()
await expect(redo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(second.userMessageID)
await expect(firstMessage.first()).toBeVisible()
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/redo")
await expect(redo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBeUndefined()
await expect(firstMessage.first()).toBeVisible()
await expect(secondMessage.first()).toBeVisible()
})
})
})
+31 -14
View File
@@ -34,21 +34,34 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
const stamp = Date.now()
const originalTitle = `e2e rename test ${stamp}`
const newTitle = `e2e renamed ${stamp}`
const renamedTitle = `e2e renamed ${stamp}`
await withSession(sdk, originalTitle, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i)
const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await input.fill(newTitle)
await expect(input).toBeFocused()
await input.fill(renamedTitle)
await expect(input).toHaveValue(renamedTitle)
await input.press("Enter")
await expect(page.getByRole("heading", { level: 1 }).first()).toContainText(newTitle)
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.title
},
{ timeout: 30_000 },
)
.toBe(renamedTitle)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
})
})
@@ -116,8 +129,14 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
await seedMessage(sdk, session.id)
await gotoSession(session.id)
const { rightSection, popoverBody } = await openSharePopover(page)
await popoverBody.getByRole("button", { name: "Publish" }).first().click()
const shared = await openSharePopover(page)
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
await expect(publish).toBeVisible({ timeout: 30_000 })
await publish.click()
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
timeout: 30_000,
})
await expect
.poll(
@@ -129,14 +148,14 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
)
.not.toBeUndefined()
const copyButton = rightSection.locator('button[aria-label="Copy link"]').first()
await expect(copyButton).toBeVisible({ timeout: 30_000 })
const sharedPopover = await openSharePopover(page)
const unpublish = sharedPopover.popoverBody.getByRole("button", { name: "Unpublish" }).first()
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
await expect(unpublish).toBeVisible({ timeout: 30_000 })
await unpublish.click()
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000,
})
await expect
.poll(
async () => {
@@ -147,10 +166,8 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
)
.toBeUndefined()
await expect(copyButton).not.toBeVisible({ timeout: 30_000 })
const unsharedPopover = await openSharePopover(page)
await expect(unsharedPopover.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
const unshared = await openSharePopover(page)
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000,
})
})
@@ -9,7 +9,7 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle")).first()
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
@@ -51,6 +51,40 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
expect(finalClosed).toBe(initiallyClosed)
})
test("sidebar toggle keybind guards against shortcut conflicts", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("B")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyP`)
await page.waitForTimeout(100)
const toast = page.locator('[data-component="toast"]').last()
await expect(toast).toBeVisible()
await expect(toast).toContainText(/already/i)
await keybindButton.click()
await expect(keybindButton).toContainText("B")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["sidebar.toggle"]).toBeUndefined()
await closeDialog(page, dialog)
})
test("resetting all keybinds to defaults works", async ({ page, gotoSession }) => {
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "sidebar.toggle": "mod+shift+x" } }))
@@ -277,6 +311,44 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
await expect(terminal).not.toBeVisible()
})
test("terminal toggle keybind persists after reload", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("terminal.toggle"))
await expect(keybindButton).toBeVisible()
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyY`)
await page.waitForTimeout(100)
await expect(keybindButton).toContainText("Y")
await closeDialog(page, dialog)
await page.reload()
await expect
.poll(async () => {
return await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
if (!raw) return
const parsed = JSON.parse(raw)
return parsed?.keybinds?.["terminal.toggle"]
})
})
.toBe("mod+shift+y")
const reloaded = await openSettings(page)
await reloaded.getByRole("tab", { name: "Shortcuts" }).click()
const reloadedKeybind = reloaded.locator(keybindButtonSelector("terminal.toggle")).first()
await expect(reloadedKeybind).toContainText("Y")
await closeDialog(page, reloaded)
})
test("changing command palette keybind works", async ({ page, gotoSession }) => {
await gotoSession()
+162
View File
@@ -9,6 +9,8 @@ import {
settingsNotificationsPermissionsSelector,
settingsReleaseNotesSelector,
settingsSoundsAgentSelector,
settingsSoundsErrorsSelector,
settingsSoundsPermissionsSelector,
settingsThemeSelector,
settingsUpdatesStartupSelector,
} from "../selectors"
@@ -139,6 +141,105 @@ test("changing font persists in localStorage and updates CSS variable", async ({
expect(newFontFamily).not.toBe(initialFontFamily)
})
test("color scheme and font rehydrate after reload", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const colorSchemeSelect = dialog.locator(settingsColorSchemeSelector)
await expect(colorSchemeSelect).toBeVisible()
await colorSchemeSelect.locator('[data-slot="select-select-trigger"]').click()
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
const fontSelect = dialog.locator(settingsFontSelector)
await expect(fontSelect).toBeVisible()
const initialFontFamily = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
})
const initialSettings = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
const currentFont =
(await fontSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
await fontSelect.locator('[data-slot="select-select-trigger"]').click()
const fontItems = page.locator('[data-slot="select-select-item"]')
expect(await fontItems.count()).toBeGreaterThan(1)
if (currentFont) {
await fontItems.filter({ hasNotText: currentFont }).first().click()
}
if (!currentFont) {
await fontItems.nth(1).click()
}
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
appearance: {
font: expect.any(String),
},
})
const updatedSettings = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
const updatedFontFamily = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
})
expect(updatedFontFamily).not.toBe(initialFontFamily)
expect(updatedSettings?.appearance?.font).not.toBe(initialSettings?.appearance?.font)
await closeDialog(page, dialog)
await page.reload()
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
appearance: {
font: updatedSettings?.appearance?.font,
},
})
const rehydratedSettings = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
await expect
.poll(async () => {
return await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
})
})
.not.toBe(initialFontFamily)
const rehydratedFontFamily = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
})
expect(rehydratedFontFamily).not.toBe(initialFontFamily)
expect(rehydratedSettings?.appearance?.font).toBe(updatedSettings?.appearance?.font)
})
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
@@ -234,6 +335,67 @@ test("changing sound agent selection persists in localStorage", async ({ page, g
expect(stored?.sounds?.agent).not.toBe("staplebops-01")
})
test("changing permissions and errors sounds updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const permissionsSelect = dialog.locator(settingsSoundsPermissionsSelector)
const errorsSelect = dialog.locator(settingsSoundsErrorsSelector)
await expect(permissionsSelect).toBeVisible()
await expect(errorsSelect).toBeVisible()
const initial = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
const permissionsCurrent =
(await permissionsSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
await permissionsSelect.locator('[data-slot="select-select-trigger"]').click()
const permissionItems = page.locator('[data-slot="select-select-item"]')
expect(await permissionItems.count()).toBeGreaterThan(1)
if (permissionsCurrent) {
await permissionItems.filter({ hasNotText: permissionsCurrent }).first().click()
}
if (!permissionsCurrent) {
await permissionItems.nth(1).click()
}
const errorsCurrent =
(await errorsSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
await errorsSelect.locator('[data-slot="select-select-trigger"]').click()
const errorItems = page.locator('[data-slot="select-select-item"]')
expect(await errorItems.count()).toBeGreaterThan(1)
if (errorsCurrent) {
await errorItems.filter({ hasNotText: errorsCurrent }).first().click()
}
if (!errorsCurrent) {
await errorItems.nth(1).click()
}
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
sounds: {
permissions: expect.any(String),
errors: expect.any(String),
},
})
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.sounds?.permissions).not.toBe(initial?.sounds?.permissions)
expect(stored?.sounds?.errors).not.toBe(initial?.sounds?.errors)
})
test("toggling updates startup switch updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
+24 -1
View File
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
import { openSidebar, toggleSidebar } from "../actions"
import { openSidebar, toggleSidebar, withSession } from "../actions"
test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
await gotoSession()
@@ -12,3 +12,26 @@ test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
await toggleSidebar(page)
await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
})
test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, "sidebar persist session 1", async (session1) => {
await withSession(sdk, "sidebar persist session 2", async (session2) => {
await gotoSession(session1.id)
await openSidebar(page)
await toggleSidebar(page)
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
await gotoSession(session2.id)
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
await page.reload()
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
const opened = await page.evaluate(
() => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened,
)
await expect(opened).toBe(false)
})
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.56",
"version": "1.1.63",
"description": "",
"type": "module",
"exports": {
+100 -75
View File
@@ -1,5 +1,5 @@
import "@/index.css"
import { ErrorBoundary, Show, lazy, type ParentProps } from "solid-js"
import { ErrorBoundary, Suspense, lazy, type JSX, type ParentProps } from "solid-js"
import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font"
@@ -30,12 +30,26 @@ import { HighlightsProvider } from "@/context/highlights"
import Layout from "@/pages/layout"
import DirectoryLayout from "@/pages/directory-layout"
import { ErrorPage } from "./pages/error"
import { Suspense, JSX } from "solid-js"
const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
const Loading = () => <div class="size-full" />
const HomeRoute = () => (
<Suspense fallback={<Loading />}>
<Home />
</Suspense>
)
const SessionRoute = () => (
<SessionProviders>
<Suspense fallback={<Loading />}>
<Session />
</Suspense>
</SessionProviders>
)
const SessionIndexRoute = () => <Navigate href="session" />
function UiI18nBridge(props: ParentProps) {
const language = useLanguage()
return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider>
@@ -43,7 +57,7 @@ function UiI18nBridge(props: ParentProps) {
declare global {
interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[] }
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[]; wsl?: boolean }
}
}
@@ -52,6 +66,71 @@ function MarkedProviderWithNativeParser(props: ParentProps) {
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
}
function AppShellProviders(props: ParentProps) {
return (
<SettingsProvider>
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
<ModelsProvider>
<CommandProvider>
<HighlightsProvider>
<Layout>{props.children}</Layout>
</HighlightsProvider>
</CommandProvider>
</ModelsProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
</SettingsProvider>
)
}
function SessionProviders(props: ParentProps) {
return (
<TerminalProvider>
<FileProvider>
<PromptProvider>
<CommentsProvider>{props.children}</CommentsProvider>
</PromptProvider>
</FileProvider>
</TerminalProvider>
)
}
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
return (
<AppShellProviders>
{props.appChildren}
{props.children}
</AppShellProviders>
)
}
const getStoredDefaultServerUrl = (platform: ReturnType<typeof usePlatform>) => {
if (platform.platform !== "web") return
const result = platform.getDefaultServerUrl?.()
if (result instanceof Promise) return
if (!result) return
return normalizeServerUrl(result)
}
const resolveDefaultServerUrl = (props: {
defaultUrl?: string
storedDefaultServerUrl?: string
hostname: string
origin: string
isDev: boolean
devHost?: string
devPort?: string
}) => {
if (props.defaultUrl) return props.defaultUrl
if (props.storedDefaultServerUrl) return props.storedDefaultServerUrl
if (props.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (props.isDev) return `http://${props.devHost ?? "localhost"}:${props.devPort ?? "4096"}`
return props.origin
}
export function AppBaseProviders(props: ParentProps) {
return (
<MetaProvider>
@@ -77,89 +156,35 @@ export function AppBaseProviders(props: ParentProps) {
function ServerKey(props: ParentProps) {
const server = useServer()
return (
<Show when={server.url} keyed>
{props.children}
</Show>
)
if (!server.url) return null
return props.children
}
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
const platform = usePlatform()
const stored = (() => {
if (platform.platform !== "web") return
const result = platform.getDefaultServerUrl?.()
if (result instanceof Promise) return
if (!result) return
return normalizeServerUrl(result)
})()
const defaultServerUrl = () => {
if (props.defaultUrl) return props.defaultUrl
if (stored) return stored
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return window.location.origin
}
const storedDefaultServerUrl = getStoredDefaultServerUrl(platform)
const defaultServerUrl = resolveDefaultServerUrl({
defaultUrl: props.defaultUrl,
storedDefaultServerUrl,
hostname: location.hostname,
origin: window.location.origin,
isDev: import.meta.env.DEV,
devHost: import.meta.env.VITE_OPENCODE_SERVER_HOST,
devPort: import.meta.env.VITE_OPENCODE_SERVER_PORT,
})
return (
<ServerProvider defaultUrl={defaultServerUrl()} isSidecar={props.isSidecar}>
<ServerProvider defaultUrl={defaultServerUrl} isSidecar={props.isSidecar}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Router
root={(routerProps) => (
<SettingsProvider>
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
<ModelsProvider>
<CommandProvider>
<HighlightsProvider>
<Layout>
{props.children}
{routerProps.children}
</Layout>
</HighlightsProvider>
</CommandProvider>
</ModelsProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
</SettingsProvider>
)}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
>
<Route
path="/"
component={() => (
<Suspense fallback={<Loading />}>
<Home />
</Suspense>
)}
/>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id ?? "new"}>
<TerminalProvider>
<FileProvider>
<PromptProvider>
<CommentsProvider>
<Suspense fallback={<Loading />}>
<Session />
</Suspense>
</CommentsProvider>
</PromptProvider>
</FileProvider>
</TerminalProvider>
</Show>
)}
/>
<Route path="/" component={SessionIndexRoute} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Router>
</GlobalSyncProvider>
@@ -10,7 +10,6 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { iife } from "@opencode-ai/util/iife"
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
@@ -55,6 +54,47 @@ export function DialogConnectProvider(props: { provider: string }) {
error: undefined as string | undefined,
})
type Action =
| { type: "method.select"; index: number }
| { type: "method.reset" }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
| { type: "auth.error"; error: string }
function dispatch(action: Action) {
setStore(
produce((draft) => {
if (action.type === "method.select") {
draft.methodIndex = action.index
draft.authorization = undefined
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "method.reset") {
draft.methodIndex = undefined
draft.authorization = undefined
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "auth.pending") {
draft.state = "pending"
draft.error = undefined
return
}
if (action.type === "auth.complete") {
draft.state = "complete"
draft.authorization = action.authorization
draft.error = undefined
return
}
draft.state = "error"
draft.error = action.error
}),
)
}
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
const methodLabel = (value?: { type?: string; label?: string }) => {
@@ -70,17 +110,10 @@ export function DialogConnectProvider(props: { provider: string }) {
}
const method = methods()[index]
setStore(
produce((draft) => {
draft.methodIndex = index
draft.authorization = undefined
draft.state = undefined
draft.error = undefined
}),
)
dispatch({ type: "method.select", index })
if (method.type === "oauth") {
setStore("state", "pending")
dispatch({ type: "auth.pending" })
const start = Date.now()
await globalSDK.client.provider.oauth
.authorize(
@@ -100,18 +133,15 @@ export function DialogConnectProvider(props: { provider: string }) {
timer.current = setTimeout(() => {
timer.current = undefined
if (!alive.value) return
setStore("state", "complete")
setStore("authorization", x.data!)
dispatch({ type: "auth.complete", authorization: x.data! })
}, delay)
return
}
setStore("state", "complete")
setStore("authorization", x.data!)
dispatch({ type: "auth.complete", authorization: x.data! })
})
.catch((e) => {
if (!alive.value) return
setStore("state", "error")
setStore("error", String(e))
dispatch({ type: "auth.error", error: String(e) })
})
}
}
@@ -129,10 +159,6 @@ export function DialogConnectProvider(props: { provider: string }) {
if (methods().length === 1) {
selectMethod(0)
}
document.addEventListener("keydown", handleKey)
onCleanup(() => {
document.removeEventListener("keydown", handleKey)
})
})
async function complete() {
@@ -152,17 +178,244 @@ export function DialogConnectProvider(props: { provider: string }) {
return
}
if (store.authorization) {
setStore("authorization", undefined)
setStore("methodIndex", undefined)
dispatch({ type: "method.reset" })
return
}
if (store.methodIndex) {
setStore("methodIndex", undefined)
if (store.methodIndex !== undefined) {
dispatch({ type: "method.reset" })
return
}
dialog.show(() => <DialogSelectProvider />)
}
function MethodSelection() {
return (
<>
<div class="text-14-regular text-text-base">
{language.t("provider.connect.selectMethod", { provider: provider().name })}
</div>
<div>
<List
ref={(ref) => {
listRef = ref
}}
items={methods}
key={(m) => m?.label}
onSelect={async (selected, index) => {
if (!selected) return
selectMethod(index)
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-2">
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
</div>
<span>{methodLabel(i)}</span>
</div>
)}
</List>
</div>
</>
)
}
function ApiAuthView() {
const [formStore, setFormStore] = createStore({
value: "",
error: undefined as string | undefined,
})
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const form = e.currentTarget as HTMLFormElement
const formData = new FormData(form)
const apiKey = formData.get("apiKey") as string
if (!apiKey?.trim()) {
setFormStore("error", language.t("provider.connect.apiKey.required"))
return
}
setFormStore("error", undefined)
await globalSDK.client.auth.set({
providerID: props.provider,
auth: {
type: "api",
key: apiKey,
},
})
await complete()
}
return (
<div class="flex flex-col gap-6">
<Switch>
<Match when={provider().id === "opencode"}>
<div class="flex flex-col gap-4">
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line1")}</div>
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div>
<div class="text-14-regular text-text-base">
{language.t("provider.connect.opencodeZen.visit.prefix")}
<Link href="https://opencode.ai/zen" tabIndex={-1}>
{language.t("provider.connect.opencodeZen.visit.link")}
</Link>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
</div>
</Match>
<Match when={true}>
<div class="text-14-regular text-text-base">
{language.t("provider.connect.apiKey.description", { provider: provider().name })}
</div>
</Match>
</Switch>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<TextField
autofocus
type="text"
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
placeholder={language.t("provider.connect.apiKey.placeholder")}
name="apiKey"
value={formStore.value}
onChange={(v) => setFormStore("value", v)}
validationState={formStore.error ? "invalid" : undefined}
error={formStore.error}
/>
<Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.submit")}
</Button>
</form>
</div>
)
}
function OAuthCodeView() {
const [formStore, setFormStore] = createStore({
value: "",
error: undefined as string | undefined,
})
onMount(() => {
if (store.authorization?.method === "code" && store.authorization?.url) {
platform.openLink(store.authorization.url)
}
})
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const form = e.currentTarget as HTMLFormElement
const formData = new FormData(form)
const code = formData.get("code") as string
if (!code?.trim()) {
setFormStore("error", language.t("provider.connect.oauth.code.required"))
return
}
setFormStore("error", undefined)
const result = await globalSDK.client.provider.oauth
.callback({
providerID: props.provider,
method: store.methodIndex,
code,
})
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (result.ok) {
await complete()
return
}
const message = result.error instanceof Error ? result.error.message : String(result.error)
setFormStore("error", message || language.t("provider.connect.oauth.code.invalid"))
}
return (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.code.visit.prefix")}
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<TextField
autofocus
type="text"
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
placeholder={language.t("provider.connect.oauth.code.placeholder")}
name="code"
value={formStore.value}
onChange={(v) => setFormStore("value", v)}
validationState={formStore.error ? "invalid" : undefined}
error={formStore.error}
/>
<Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.submit")}
</Button>
</form>
</div>
)
}
function OAuthAutoView() {
const code = createMemo(() => {
const instructions = store.authorization?.instructions
if (instructions?.includes(":")) {
return instructions.split(":")[1]?.trim()
}
return instructions
})
onMount(() => {
void (async () => {
if (store.authorization?.url) {
platform.openLink(store.authorization.url)
}
const result = await globalSDK.client.provider.oauth
.callback({
providerID: props.provider,
method: store.methodIndex,
})
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (!alive.value) return
if (!result.ok) {
const message = result.error instanceof Error ? result.error.message : String(result.error)
dispatch({ type: "auth.error", error: message })
return
}
await complete()
})()
})
return (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")}
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
</div>
<TextField
label={language.t("provider.connect.oauth.auto.confirmationCode")}
class="font-mono"
value={code()}
readOnly
copyable
/>
<div class="text-14-regular text-text-base flex items-center gap-4">
<Spinner />
<span>{language.t("provider.connect.status.waiting")}</span>
</div>
</div>
)
}
return (
<Dialog
title={
@@ -188,267 +441,42 @@ export function DialogConnectProvider(props: { provider: string }) {
</div>
</div>
<div class="px-2.5 pb-10 flex flex-col gap-6">
<Switch>
<Match when={store.methodIndex === undefined}>
<div class="text-14-regular text-text-base">
{language.t("provider.connect.selectMethod", { provider: provider().name })}
</div>
<div class="">
<List
ref={(ref) => {
listRef = ref
}}
items={methods}
key={(m) => m?.label}
onSelect={async (method, index) => {
if (!method) return
selectMethod(index)
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-2">
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
</div>
<span>{methodLabel(i)}</span>
</div>
)}
</List>
</div>
</Match>
<Match when={store.state === "pending"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={store.state === "error"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
</div>
</div>
</Match>
<Match when={method()?.type === "api"}>
{iife(() => {
const [formStore, setFormStore] = createStore({
value: "",
error: undefined as string | undefined,
})
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const form = e.currentTarget as HTMLFormElement
const formData = new FormData(form)
const apiKey = formData.get("apiKey") as string
if (!apiKey?.trim()) {
setFormStore("error", language.t("provider.connect.apiKey.required"))
return
}
setFormStore("error", undefined)
await globalSDK.client.auth.set({
providerID: props.provider,
auth: {
type: "api",
key: apiKey,
},
})
await complete()
}
return (
<div class="flex flex-col gap-6">
<Switch>
<Match when={provider().id === "opencode"}>
<div class="flex flex-col gap-4">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.opencodeZen.line1")}
</div>
<div class="text-14-regular text-text-base">
{language.t("provider.connect.opencodeZen.line2")}
</div>
<div class="text-14-regular text-text-base">
{language.t("provider.connect.opencodeZen.visit.prefix")}
<Link href="https://opencode.ai/zen" tabIndex={-1}>
{language.t("provider.connect.opencodeZen.visit.link")}
</Link>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
</div>
</Match>
<Match when={true}>
<div class="text-14-regular text-text-base">
{language.t("provider.connect.apiKey.description", { provider: provider().name })}
</div>
</Match>
</Switch>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<TextField
autofocus
type="text"
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
placeholder={language.t("provider.connect.apiKey.placeholder")}
name="apiKey"
value={formStore.value}
onChange={setFormStore.bind(null, "value")}
validationState={formStore.error ? "invalid" : undefined}
error={formStore.error}
/>
<Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.submit")}
</Button>
</form>
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
<Switch>
<Match when={store.methodIndex === undefined}>
<MethodSelection />
</Match>
<Match when={store.state === "pending"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
)
})}
</Match>
<Match when={method()?.type === "oauth"}>
<Switch>
<Match when={store.authorization?.method === "code"}>
{iife(() => {
const [formStore, setFormStore] = createStore({
value: "",
error: undefined as string | undefined,
})
onMount(() => {
if (store.authorization?.method === "code" && store.authorization?.url) {
platform.openLink(store.authorization.url)
}
})
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const form = e.currentTarget as HTMLFormElement
const formData = new FormData(form)
const code = formData.get("code") as string
if (!code?.trim()) {
setFormStore("error", language.t("provider.connect.oauth.code.required"))
return
}
setFormStore("error", undefined)
const result = await globalSDK.client.provider.oauth
.callback({
providerID: props.provider,
method: store.methodIndex,
code,
})
.then((value) =>
value.error ? { ok: false as const, error: value.error } : { ok: true as const },
)
.catch((error) => ({ ok: false as const, error }))
if (result.ok) {
await complete()
return
}
const message = result.error instanceof Error ? result.error.message : String(result.error)
setFormStore("error", message || language.t("provider.connect.oauth.code.invalid"))
}
return (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.code.visit.prefix")}
<Link href={store.authorization!.url}>
{language.t("provider.connect.oauth.code.visit.link")}
</Link>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<TextField
autofocus
type="text"
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
placeholder={language.t("provider.connect.oauth.code.placeholder")}
name="code"
value={formStore.value}
onChange={setFormStore.bind(null, "value")}
validationState={formStore.error ? "invalid" : undefined}
error={formStore.error}
/>
<Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.submit")}
</Button>
</form>
</div>
)
})}
</Match>
<Match when={store.authorization?.method === "auto"}>
{iife(() => {
const code = createMemo(() => {
const instructions = store.authorization?.instructions
if (instructions?.includes(":")) {
return instructions?.split(":")[1]?.trim()
}
return instructions
})
onMount(() => {
void (async () => {
if (store.authorization?.url) {
platform.openLink(store.authorization.url)
}
const result = await globalSDK.client.provider.oauth
.callback({
providerID: props.provider,
method: store.methodIndex,
})
.then((value) =>
value.error ? { ok: false as const, error: value.error } : { ok: true as const },
)
.catch((error) => ({ ok: false as const, error }))
if (!alive.value) return
if (!result.ok) {
const message = result.error instanceof Error ? result.error.message : String(result.error)
setStore("state", "error")
setStore("error", message)
return
}
await complete()
})()
})
return (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")}
<Link href={store.authorization!.url}>
{language.t("provider.connect.oauth.auto.visit.link")}
</Link>
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
</div>
<TextField
label={language.t("provider.connect.oauth.auto.confirmationCode")}
class="font-mono"
value={code()}
readOnly
copyable
/>
<div class="text-14-regular text-text-base flex items-center gap-4">
<Spinner />
<span>{language.t("provider.connect.status.waiting")}</span>
</div>
</div>
)
})}
</Match>
</Switch>
</Match>
</Switch>
</div>
</Match>
<Match when={store.state === "error"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
</div>
</div>
</Match>
<Match when={method()?.type === "api"}>
<ApiAuthView />
</Match>
<Match when={method()?.type === "oauth"}>
<Switch>
<Match when={store.authorization?.method === "code"}>
<OAuthCodeView />
</Match>
<Match when={store.authorization?.method === "auto"}>
<OAuthAutoView />
</Match>
</Switch>
</Match>
</Switch>
</div>
</div>
</div>
</Dialog>
@@ -6,7 +6,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { For } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createStore } from "solid-js/store"
import { Link } from "@/components/link"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
@@ -16,6 +16,147 @@ import { DialogSelectProvider } from "./dialog-select-provider"
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
type Translator = ReturnType<typeof useLanguage>["t"]
type ModelRow = {
id: string
name: string
}
type HeaderRow = {
key: string
value: string
}
type FormState = {
providerID: string
name: string
baseURL: string
apiKey: string
models: ModelRow[]
headers: HeaderRow[]
saving: boolean
}
type FormErrors = {
providerID: string | undefined
name: string | undefined
baseURL: string | undefined
models: Array<{ id?: string; name?: string }>
headers: Array<{ key?: string; value?: string }>
}
type ValidateArgs = {
form: FormState
t: Translator
disabledProviders: string[]
existingProviderIDs: Set<string>
}
function validateCustomProvider(input: ValidateArgs) {
const providerID = input.form.providerID.trim()
const name = input.form.name.trim()
const baseURL = input.form.baseURL.trim()
const apiKey = input.form.apiKey.trim()
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
const key = apiKey && !env ? apiKey : undefined
const idError = !providerID
? input.t("provider.custom.error.providerID.required")
: !PROVIDER_ID.test(providerID)
? input.t("provider.custom.error.providerID.format")
: undefined
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
const urlError = !baseURL
? input.t("provider.custom.error.baseURL.required")
: !/^https?:\/\//.test(baseURL)
? input.t("provider.custom.error.baseURL.format")
: undefined
const disabled = input.disabledProviders.includes(providerID)
const existsError = idError
? undefined
: input.existingProviderIDs.has(providerID) && !disabled
? input.t("provider.custom.error.providerID.exists")
: undefined
const seenModels = new Set<string>()
const modelErrors = input.form.models.map((m) => {
const id = m.id.trim()
const modelIdError = !id
? input.t("provider.custom.error.required")
: seenModels.has(id)
? input.t("provider.custom.error.duplicate")
: (() => {
seenModels.add(id)
return undefined
})()
const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
return { id: modelIdError, name: modelNameError }
})
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
const seenHeaders = new Set<string>()
const headerErrors = input.form.headers.map((h) => {
const key = h.key.trim()
const value = h.value.trim()
if (!key && !value) return {}
const keyError = !key
? input.t("provider.custom.error.required")
: seenHeaders.has(key.toLowerCase())
? input.t("provider.custom.error.duplicate")
: (() => {
seenHeaders.add(key.toLowerCase())
return undefined
})()
const valueError = !value ? input.t("provider.custom.error.required") : undefined
return { key: keyError, value: valueError }
})
const headersValid = headerErrors.every((h) => !h.key && !h.value)
const headers = Object.fromEntries(
input.form.headers
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
.filter((h) => !!h.key && !!h.value)
.map((h) => [h.key, h.value]),
)
const errors: FormErrors = {
providerID: idError ?? existsError,
name: nameError,
baseURL: urlError,
models: modelErrors,
headers: headerErrors,
}
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
if (!ok) return { errors }
const options = {
baseURL,
...(Object.keys(headers).length ? { headers } : {}),
}
return {
errors,
result: {
providerID,
name,
key,
config: {
npm: OPENAI_COMPATIBLE,
name,
...(env ? { env: [env] } : {}),
options,
models,
},
},
}
}
type Props = {
back?: "providers" | "close"
}
@@ -26,7 +167,7 @@ export function DialogCustomProvider(props: Props) {
const globalSDK = useGlobalSDK()
const language = useLanguage()
const [form, setForm] = createStore({
const [form, setForm] = createStore<FormState>({
providerID: "",
name: "",
baseURL: "",
@@ -36,12 +177,12 @@ export function DialogCustomProvider(props: Props) {
saving: false,
})
const [errors, setErrors] = createStore({
providerID: undefined as string | undefined,
name: undefined as string | undefined,
baseURL: undefined as string | undefined,
models: [{} as { id?: string; name?: string }],
headers: [{} as { key?: string; value?: string }],
const [errors, setErrors] = createStore<FormErrors>({
providerID: undefined,
name: undefined,
baseURL: undefined,
models: [{}],
headers: [{}],
})
const goBack = () => {
@@ -53,169 +194,36 @@ export function DialogCustomProvider(props: Props) {
}
const addModel = () => {
setForm(
"models",
produce((draft) => {
draft.push({ id: "", name: "" })
}),
)
setErrors(
"models",
produce((draft) => {
draft.push({})
}),
)
setForm("models", (v) => [...v, { id: "", name: "" }])
setErrors("models", (v) => [...v, {}])
}
const removeModel = (index: number) => {
if (form.models.length <= 1) return
setForm(
"models",
produce((draft) => {
draft.splice(index, 1)
}),
)
setErrors(
"models",
produce((draft) => {
draft.splice(index, 1)
}),
)
setForm("models", (v) => v.filter((_, i) => i !== index))
setErrors("models", (v) => v.filter((_, i) => i !== index))
}
const addHeader = () => {
setForm(
"headers",
produce((draft) => {
draft.push({ key: "", value: "" })
}),
)
setErrors(
"headers",
produce((draft) => {
draft.push({})
}),
)
setForm("headers", (v) => [...v, { key: "", value: "" }])
setErrors("headers", (v) => [...v, {}])
}
const removeHeader = (index: number) => {
if (form.headers.length <= 1) return
setForm(
"headers",
produce((draft) => {
draft.splice(index, 1)
}),
)
setErrors(
"headers",
produce((draft) => {
draft.splice(index, 1)
}),
)
setForm("headers", (v) => v.filter((_, i) => i !== index))
setErrors("headers", (v) => v.filter((_, i) => i !== index))
}
const validate = () => {
const providerID = form.providerID.trim()
const name = form.name.trim()
const baseURL = form.baseURL.trim()
const apiKey = form.apiKey.trim()
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
const key = apiKey && !env ? apiKey : undefined
const idError = !providerID
? language.t("provider.custom.error.providerID.required")
: !PROVIDER_ID.test(providerID)
? language.t("provider.custom.error.providerID.format")
: undefined
const nameError = !name ? language.t("provider.custom.error.name.required") : undefined
const urlError = !baseURL
? language.t("provider.custom.error.baseURL.required")
: !/^https?:\/\//.test(baseURL)
? language.t("provider.custom.error.baseURL.format")
: undefined
const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID)
const existingProvider = globalSync.data.provider.all.find((p) => p.id === providerID)
const existsError = idError
? undefined
: existingProvider && !disabled
? language.t("provider.custom.error.providerID.exists")
: undefined
const seenModels = new Set<string>()
const modelErrors = form.models.map((m) => {
const id = m.id.trim()
const modelIdError = !id
? language.t("provider.custom.error.required")
: seenModels.has(id)
? language.t("provider.custom.error.duplicate")
: (() => {
seenModels.add(id)
return undefined
})()
const modelNameError = !m.name.trim() ? language.t("provider.custom.error.required") : undefined
return { id: modelIdError, name: modelNameError }
const output = validateCustomProvider({
form,
t: language.t,
disabledProviders: globalSync.data.config.disabled_providers ?? [],
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
})
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
const models = Object.fromEntries(form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
const seenHeaders = new Set<string>()
const headerErrors = form.headers.map((h) => {
const key = h.key.trim()
const value = h.value.trim()
if (!key && !value) return {}
const keyError = !key
? language.t("provider.custom.error.required")
: seenHeaders.has(key.toLowerCase())
? language.t("provider.custom.error.duplicate")
: (() => {
seenHeaders.add(key.toLowerCase())
return undefined
})()
const valueError = !value ? language.t("provider.custom.error.required") : undefined
return { key: keyError, value: valueError }
})
const headersValid = headerErrors.every((h) => !h.key && !h.value)
const headers = Object.fromEntries(
form.headers
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
.filter((h) => !!h.key && !!h.value)
.map((h) => [h.key, h.value]),
)
setErrors(
produce((draft) => {
draft.providerID = idError ?? existsError
draft.name = nameError
draft.baseURL = urlError
draft.models = modelErrors
draft.headers = headerErrors
}),
)
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
if (!ok) return
const options = {
baseURL,
...(Object.keys(headers).length ? { headers } : {}),
}
return {
providerID,
name,
key,
config: {
npm: OPENAI_COMPATIBLE,
name,
...(env ? { env: [env] } : {}),
options,
models,
},
}
setErrors(output.errors)
return output.result
}
const save = async (e: SubmitEvent) => {
@@ -297,7 +305,7 @@ export function DialogCustomProvider(props: Props) {
placeholder={language.t("provider.custom.field.providerID.placeholder")}
description={language.t("provider.custom.field.providerID.description")}
value={form.providerID}
onChange={setForm.bind(null, "providerID")}
onChange={(v) => setForm("providerID", v)}
validationState={errors.providerID ? "invalid" : undefined}
error={errors.providerID}
/>
@@ -305,7 +313,7 @@ export function DialogCustomProvider(props: Props) {
label={language.t("provider.custom.field.name.label")}
placeholder={language.t("provider.custom.field.name.placeholder")}
value={form.name}
onChange={setForm.bind(null, "name")}
onChange={(v) => setForm("name", v)}
validationState={errors.name ? "invalid" : undefined}
error={errors.name}
/>
@@ -313,7 +321,7 @@ export function DialogCustomProvider(props: Props) {
label={language.t("provider.custom.field.baseURL.label")}
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
value={form.baseURL}
onChange={setForm.bind(null, "baseURL")}
onChange={(v) => setForm("baseURL", v)}
validationState={errors.baseURL ? "invalid" : undefined}
error={errors.baseURL}
/>
@@ -322,7 +330,7 @@ export function DialogCustomProvider(props: Props) {
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
description={language.t("provider.custom.field.apiKey.description")}
value={form.apiKey}
onChange={setForm.bind(null, "apiKey")}
onChange={(v) => setForm("apiKey", v)}
/>
</div>
@@ -33,6 +33,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
iconHover: false,
})
let iconInput: HTMLInputElement | undefined
function handleFileSelect(file: File) {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
@@ -72,31 +74,35 @@ export function DialogEditProject(props: { project: LocalProject }) {
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
setStore("saving", true)
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
await Promise.resolve()
.then(async () => {
setStore("saving", true)
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await globalSDK.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color, override: store.iconUrl },
commands: { start },
if (props.project.id && props.project.id !== "global") {
await globalSDK.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color, override: store.iconUrl },
commands: { start },
})
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
dialog.close()
return
}
globalSync.project.meta(props.project.worktree, {
name,
icon: { color: store.color, override: store.iconUrl || undefined },
commands: { start: start || undefined },
})
dialog.close()
})
.finally(() => {
setStore("saving", false)
})
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
setStore("saving", false)
dialog.close()
return
}
globalSync.project.meta(props.project.worktree, {
name,
icon: { color: store.color, override: store.iconUrl || undefined },
commands: { start: start || undefined },
})
setStore("saving", false)
dialog.close()
}
return (
@@ -134,7 +140,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
if (store.iconUrl && store.iconHover) {
clearIcon()
} else {
document.getElementById("icon-upload")?.click()
iconInput?.click()
}
}}
>
@@ -176,7 +182,16 @@ export function DialogEditProject(props: { project: LocalProject }) {
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
</div>
</div>
<input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
<input
id="icon-upload"
ref={(el) => {
iconInput = el
}}
type="file"
accept="image/*"
class="hidden"
onChange={handleInputChange}
/>
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
<span>{language.t("dialog.project.edit.icon.hint")}</span>
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
+17 -8
View File
@@ -6,6 +6,7 @@ import { usePrompt } from "@/context/prompt"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { showToast } from "@opencode-ai/ui/toast"
import { extractPromptFromParts } from "@/utils/prompt"
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/util/encode"
@@ -66,15 +67,23 @@ export const DialogFork: Component = () => {
attachmentName: language.t("common.attachment"),
})
dialog.close()
sdk.client.session.fork({ sessionID, messageID: item.id }).then((forked) => {
if (!forked.data) return
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
requestAnimationFrame(() => {
prompt.set(restored)
sdk.client.session
.fork({ sessionID, messageID: item.id })
.then((forked) => {
if (!forked.data) {
showToast({ title: language.t("common.requestFailed") })
return
}
dialog.close()
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
requestAnimationFrame(() => {
prompt.set(restored)
})
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
})
}
return (
@@ -17,6 +17,7 @@ export const DialogManageModels: Component = () => {
const handleConnectProvider = () => {
dialog.show(() => <DialogSelectProvider />)
}
const providerRank = (id: string) => popularProviders.indexOf(id)
return (
<Dialog
@@ -37,19 +38,18 @@ export const DialogManageModels: Component = () => {
sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.name}
sortGroupsBy={(a, b) => {
const aProvider = a.items[0].provider.id
const bProvider = b.items[0].provider.id
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
const aRank = providerRank(a.items[0].provider.id)
const bRank = providerRank(b.items[0].provider.id)
const aPopular = aRank >= 0
const bPopular = bRank >= 0
if (aPopular && !bPopular) return -1
if (!aPopular && bPopular) return 1
return aRank - bRank
}}
onSelect={(x) => {
if (!x) return
const visible = local.model.visible({
modelID: x.id,
providerID: x.provider.id,
})
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible)
const key = { modelID: x.id, providerID: x.provider.id }
local.model.setVisibility(key, !local.model.visible(key))
}}
>
{(i) => (
@@ -57,12 +57,7 @@ export const DialogManageModels: Component = () => {
<span>{i.name}</span>
<div onClick={(e) => e.stopPropagation()}>
<Switch
checked={
!!local.model.visible({
modelID: i.id,
providerID: i.provider.id,
})
}
checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })}
onChange={(checked) => {
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
}}
@@ -1,4 +1,4 @@
import { createSignal, createEffect, onMount, onCleanup } from "solid-js"
import { createSignal } 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"
@@ -40,8 +40,6 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
handleClose()
}
let focusTrap: HTMLDivElement | undefined
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault()
@@ -60,27 +58,13 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
}
}
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
size="large"
fit
class="w-[min(calc(100vw-40px),720px)] h-[min(calc(100vh-40px),400px)] -mt-20 min-h-0 overflow-hidden"
>
{/* Hidden element to capture initial focus and handle escape */}
<div ref={focusTrap} tabindex="0" class="absolute opacity-0 pointer-events-none" />
<div class="flex flex-1 min-w-0 min-h-0">
<div class="flex flex-1 min-w-0 min-h-0" tabIndex={0} autofocus onKeyDown={handleKeyDown}>
{/* Left side - Text content */}
<div class="flex flex-col flex-1 min-w-0 p-8">
{/* Top section - feature content (fixed position from top) */}
@@ -2,13 +2,13 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { List } from "@opencode-ai/ui/list"
import type { ListRef } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import fuzzysort from "fuzzysort"
import { createMemo, createResource, createSignal } from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import type { ListRef } from "@opencode-ai/ui/list"
interface DialogSelectDirectoryProps {
title?: string
@@ -21,157 +21,131 @@ type Row = {
search: string
}
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const sync = useGlobalSync()
const sdk = useGlobalSDK()
const dialog = useDialog()
const language = useLanguage()
function cleanInput(value: string) {
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
}
const [filter, setFilter] = createSignal("")
function normalizePath(input: string) {
const v = input.replaceAll("\\", "/")
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
return v.replace(/\/+/g, "/")
}
let list: ListRef | undefined
function normalizeDriveRoot(input: string) {
const v = normalizePath(input)
if (/^[A-Za-z]:$/.test(v)) return v + "/"
return v
}
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
function trimTrailing(input: string) {
const v = normalizeDriveRoot(input)
if (v === "/") return v
if (v === "//") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
return v.replace(/\/+$/, "")
}
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
async () => {
return sdk.client.path
.get()
.then((x) => x.data)
.catch(() => undefined)
},
{ initialValue: undefined },
)
function joinPath(base: string | undefined, rel: string) {
const b = trimTrailing(base ?? "")
const r = trimTrailing(rel).replace(/^\/+/, "")
if (!b) return r
if (!r) return b
if (b.endsWith("/")) return b + r
return b + "/" + r
}
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
function rootOf(input: string) {
const v = normalizeDriveRoot(input)
if (v.startsWith("//")) return "//"
if (v.startsWith("/")) return "/"
if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
return ""
}
const start = createMemo(
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
)
function parentOf(input: string) {
const v = trimTrailing(input)
if (v === "/") return v
if (v === "//") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
const i = v.lastIndexOf("/")
if (i <= 0) return "/"
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
return v.slice(0, i)
}
function modeOf(input: string) {
const raw = normalizeDriveRoot(input.trim())
if (!raw) return "relative" as const
if (raw.startsWith("~")) return "tilde" as const
if (rootOf(raw)) return "absolute" as const
return "relative" as const
}
function tildeOf(absolute: string, home: string) {
const full = trimTrailing(absolute)
if (!home) return ""
const hn = trimTrailing(home)
const lc = full.toLowerCase()
const hc = hn.toLowerCase()
if (lc === hc) return "~"
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
return ""
}
function displayPath(path: string, input: string, home: string) {
const full = trimTrailing(path)
if (modeOf(input) === "absolute") return full
return tildeOf(full, home) || full
}
function toRow(absolute: string, home: string): Row {
const full = trimTrailing(absolute)
const tilde = tildeOf(full, home)
const withSlash = (value: string) => {
if (!value) return ""
if (value.endsWith("/")) return value
return value + "/"
}
const search = Array.from(
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
).join("\n")
return { absolute: full, search }
}
function useDirectorySearch(args: {
sdk: ReturnType<typeof useGlobalSDK>
start: () => string | undefined
home: () => string
}) {
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
let current = 0
const clean = (value: string) => {
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
}
function normalize(input: string) {
const v = input.replaceAll("\\", "/")
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
return v.replace(/\/+/g, "/")
}
function normalizeDriveRoot(input: string) {
const v = normalize(input)
if (/^[A-Za-z]:$/.test(v)) return v + "/"
return v
}
function trimTrailing(input: string) {
const v = normalizeDriveRoot(input)
if (v === "/") return v
if (v === "//") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
return v.replace(/\/+$/, "")
}
function join(base: string | undefined, rel: string) {
const b = trimTrailing(base ?? "")
const r = trimTrailing(rel).replace(/^\/+/, "")
if (!b) return r
if (!r) return b
if (b.endsWith("/")) return b + r
return b + "/" + r
}
function rootOf(input: string) {
const v = normalizeDriveRoot(input)
if (v.startsWith("//")) return "//"
if (v.startsWith("/")) return "/"
if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
return ""
}
function parentOf(input: string) {
const v = trimTrailing(input)
if (v === "/") return v
if (v === "//") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
const i = v.lastIndexOf("/")
if (i <= 0) return "/"
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
return v.slice(0, i)
}
function modeOf(input: string) {
const raw = normalizeDriveRoot(input.trim())
if (!raw) return "relative" as const
if (raw.startsWith("~")) return "tilde" as const
if (rootOf(raw)) return "absolute" as const
return "relative" as const
}
function display(path: string, input: string) {
const full = trimTrailing(path)
if (modeOf(input) === "absolute") return full
return tildeOf(full) || full
}
function tildeOf(absolute: string) {
const full = trimTrailing(absolute)
const h = home()
if (!h) return ""
const hn = trimTrailing(h)
const lc = full.toLowerCase()
const hc = hn.toLowerCase()
if (lc === hc) return "~"
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
return ""
}
function row(absolute: string): Row {
const full = trimTrailing(absolute)
const tilde = tildeOf(full)
const withSlash = (value: string) => {
if (!value) return ""
if (value.endsWith("/")) return value
return value + "/"
}
const search = Array.from(
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
).join("\n")
return { absolute: full, search }
}
function scoped(value: string) {
const base = start()
const scoped = (value: string) => {
const base = args.start()
if (!base) return
const raw = normalizeDriveRoot(value)
if (!raw) return { directory: trimTrailing(base), path: "" }
const h = home()
if (raw === "~") return { directory: trimTrailing(h ?? base), path: "" }
if (raw.startsWith("~/")) return { directory: trimTrailing(h ?? base), path: raw.slice(2) }
const h = args.home()
if (raw === "~") return { directory: trimTrailing(h || base), path: "" }
if (raw.startsWith("~/")) return { directory: trimTrailing(h || base), path: raw.slice(2) }
const root = rootOf(raw)
if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) }
return { directory: trimTrailing(base), path: raw }
}
async function dirs(dir: string) {
const dirs = async (dir: string) => {
const key = trimTrailing(dir)
const existing = cache.get(key)
if (existing) return existing
const request = sdk.client.file
const request = args.sdk.client.file
.list({ directory: key, path: "" })
.then((x) => x.data ?? [])
.catch(() => [])
@@ -188,32 +162,34 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
return request
}
async function match(dir: string, query: string, limit: number) {
const match = async (dir: string, query: string, limit: number) => {
const items = await dirs(dir)
if (!query) return items.slice(0, limit).map((x) => x.absolute)
return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute)
}
const directories = async (filter: string) => {
const value = clean(filter)
return async (filter: string) => {
const token = ++current
const active = () => token === current
const value = cleanInput(filter)
const scopedInput = scoped(value)
if (!scopedInput) return [] as string[]
const raw = normalizeDriveRoot(value)
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
const query = normalizeDriveRoot(scopedInput.path)
const find = () =>
sdk.client.find
args.sdk.client.find
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
.then((x) => x.data ?? [])
.catch(() => [])
if (!isPath) {
const results = await find()
return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50)
if (!active()) return []
return results.map((rel) => joinPath(scopedInput.directory, rel)).slice(0, 50)
}
const segments = query.replace(/^\/+/, "").split("/")
@@ -224,17 +200,20 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const branch = 4
let paths = [scopedInput.directory]
for (const part of head) {
if (!active()) return []
if (part === "..") {
paths = paths.map(parentOf)
continue
}
const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat()
if (!active()) return []
paths = Array.from(new Set(next)).slice(0, cap)
if (paths.length === 0) return [] as string[]
}
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
if (!active()) return []
const deduped = Array.from(new Set(out))
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
const expand = !raw.endsWith("/")
@@ -249,13 +228,47 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
if (!target) return deduped.slice(0, 50)
const children = await match(target, "", 30)
if (!active()) return []
const items = Array.from(new Set([...deduped, ...children]))
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
}
}
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const sync = useGlobalSync()
const sdk = useGlobalSDK()
const dialog = useDialog()
const language = useLanguage()
const [filter, setFilter] = createSignal("")
let list: ListRef | undefined
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
async () => {
return sdk.client.path
.get()
.then((x) => x.data)
.catch(() => undefined)
},
{ initialValue: undefined },
)
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
const start = createMemo(
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
)
const directories = useDirectorySearch({
sdk,
home,
start,
})
const items = async (value: string) => {
const results = await directories(value)
return results.map(row)
return results.map((absolute) => toRow(absolute, home()))
}
function resolve(absolute: string) {
@@ -273,7 +286,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
key={(x) => x.absolute}
filterKeys={["search"]}
ref={(r) => (list = r)}
onFilter={(value) => setFilter(clean(value))}
onFilter={(value) => setFilter(cleanInput(value))}
onKeyEvent={(e, item) => {
if (e.key !== "Tab") return
if (e.shiftKey) return
@@ -282,7 +295,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
e.preventDefault()
e.stopPropagation()
const value = display(item.absolute, filter())
const value = displayPath(item.absolute, filter(), home())
list?.setFilter(value.endsWith("/") ? value : value + "/")
}}
onSelect={(path) => {
@@ -291,7 +304,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
}}
>
{(item) => {
const path = display(item.absolute, filter())
const path = displayPath(item.absolute, filter(), home())
if (path === "~") {
return (
<div class="w-full flex items-center justify-between rounded-md">
+228 -170
View File
@@ -36,6 +36,223 @@ type Entry = {
type DialogSelectFileMode = "all" | "files"
const ENTRY_LIMIT = 5
const COMMON_COMMAND_IDS = [
"session.new",
"workspace.new",
"session.previous",
"session.next",
"terminal.toggle",
"review.toggle",
] as const
const uniqueEntries = (items: Entry[]) => {
const seen = new Set<string>()
const out: Entry[] = []
for (const item of items) {
if (seen.has(item.id)) continue
seen.add(item.id)
out.push(item)
}
return out
}
const createCommandEntry = (option: CommandOption, category: string): Entry => ({
id: "command:" + option.id,
type: "command",
title: option.title,
description: option.description,
keybind: option.keybind,
category,
option,
})
const createFileEntry = (path: string, category: string): Entry => ({
id: "file:" + path,
type: "file",
title: path,
category,
path,
})
const createSessionEntry = (
input: {
directory: string
id: string
title: string
description: string
archived?: number
updated?: number
},
category: string,
): Entry => ({
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category,
directory: input.directory,
sessionID: input.id,
archived: input.archived,
updated: input.updated,
})
function createCommandEntries(props: {
filesOnly: () => boolean
command: ReturnType<typeof useCommand>
language: ReturnType<typeof useLanguage>
}) {
const allowed = createMemo(() => {
if (props.filesOnly()) return []
return props.command.options.filter(
(option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
})
const list = createMemo(() => {
const category = props.language.t("palette.group.commands")
return allowed().map((option) => createCommandEntry(option, category))
})
const picks = createMemo(() => {
const all = allowed()
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
const picked = all.filter((option) => order.has(option.id))
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
const category = props.language.t("palette.group.commands")
return sorted.map((option) => createCommandEntry(option, category))
})
return { allowed, list, picks }
}
function createFileEntries(props: {
file: ReturnType<typeof useFile>
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
language: ReturnType<typeof useLanguage>
}) {
const recent = createMemo(() => {
const all = props.tabs().all()
const active = props.tabs().active()
const order = active ? [active, ...all.filter((item) => item !== active)] : all
const seen = new Set<string>()
const category = props.language.t("palette.group.files")
const items: Entry[] = []
for (const item of order) {
const path = props.file.pathFromTab(item)
if (!path) continue
if (seen.has(path)) continue
seen.add(path)
items.push(createFileEntry(path, category))
}
return items.slice(0, ENTRY_LIMIT)
})
const root = createMemo(() => {
const category = props.language.t("palette.group.files")
const nodes = props.file.tree.children("")
const paths = nodes
.filter((node) => node.type === "file")
.map((node) => node.path)
.sort((a, b) => a.localeCompare(b))
return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category))
})
return { recent, root }
}
function createSessionEntries(props: {
workspaces: () => string[]
label: (directory: string) => string
globalSDK: ReturnType<typeof useGlobalSDK>
language: ReturnType<typeof useLanguage>
}) {
const state: {
token: number
inflight: Promise<Entry[]> | undefined
cached: Entry[] | undefined
} = {
token: 0,
inflight: undefined,
cached: undefined,
}
const sessions = (text: string) => {
const query = text.trim()
if (!query) {
state.token += 1
state.inflight = undefined
state.cached = undefined
return [] as Entry[]
}
if (state.cached) return state.cached
if (state.inflight) return state.inflight
const current = state.token
const dirs = props.workspaces()
if (dirs.length === 0) return [] as Entry[]
state.inflight = Promise.all(
dirs.map((directory) => {
const description = props.label(directory)
return props.globalSDK.client.session
.list({ directory, roots: true })
.then((x) =>
(x.data ?? [])
.filter((s) => !!s?.id)
.map((s) => ({
id: s.id,
title: s.title ?? props.language.t("command.session.new"),
description,
directory,
archived: s.time?.archived,
updated: s.time?.updated,
})),
)
.catch(
() =>
[] as {
id: string
title: string
description: string
directory: string
archived?: number
updated?: number
}[],
)
}),
)
.then((results) => {
if (state.token !== current) return [] as Entry[]
const seen = new Set<string>()
const category = props.language.t("command.category.session")
const next = results
.flat()
.filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map((item) => createSessionEntry(item, category))
state.cached = next
return next
})
.catch(() => [] as Entry[])
.finally(() => {
state.inflight = undefined
})
return state.inflight
}
return { sessions }
}
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
const command = useCommand()
const language = useLanguage()
@@ -52,40 +269,8 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const view = createMemo(() => layout.view(sessionKey))
const state = { cleanup: undefined as (() => void) | void, committed: false }
const [grouped, setGrouped] = createSignal(false)
const common = [
"session.new",
"workspace.new",
"session.previous",
"session.next",
"terminal.toggle",
"review.toggle",
]
const limit = 5
const allowed = createMemo(() => {
if (filesOnly()) return []
return command.options.filter(
(option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
})
const commandItem = (option: CommandOption): Entry => ({
id: "command:" + option.id,
type: "command",
title: option.title,
description: option.description,
keybind: option.keybind,
category: language.t("palette.group.commands"),
option,
})
const fileItem = (path: string): Entry => ({
id: "file:" + path,
type: "file",
title: path,
category: language.t("palette.group.files"),
path,
})
const commandEntries = createCommandEntries({ filesOnly, command, language })
const fileEntries = createFileEntries({ file, tabs, language })
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
@@ -116,136 +301,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
return `${kind} : ${name || path}`
}
const sessionItem = (input: {
directory: string
id: string
title: string
description: string
archived?: number
updated?: number
}): Entry => ({
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category: language.t("command.category.session"),
directory: input.directory,
sessionID: input.id,
archived: input.archived,
updated: input.updated,
})
const list = createMemo(() => allowed().map(commandItem))
const picks = createMemo(() => {
const all = allowed()
const order = new Map(common.map((id, index) => [id, index]))
const picked = all.filter((option) => order.has(option.id))
const base = picked.length ? picked : all.slice(0, limit)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
return sorted.map(commandItem)
})
const recent = createMemo(() => {
const all = tabs().all()
const active = tabs().active()
const order = active ? [active, ...all.filter((item) => item !== active)] : all
const seen = new Set<string>()
const items: Entry[] = []
for (const item of order) {
const path = file.pathFromTab(item)
if (!path) continue
if (seen.has(path)) continue
seen.add(path)
items.push(fileItem(path))
}
return items.slice(0, limit)
})
const root = createMemo(() => {
const nodes = file.tree.children("")
const paths = nodes
.filter((node) => node.type === "file")
.map((node) => node.path)
.sort((a, b) => a.localeCompare(b))
return paths.slice(0, limit).map(fileItem)
})
const unique = (items: Entry[]) => {
const seen = new Set<string>()
const out: Entry[] = []
for (const item of items) {
if (seen.has(item.id)) continue
seen.add(item.id)
out.push(item)
}
return out
}
const sessionToken = { value: 0 }
let sessionInflight: Promise<Entry[]> | undefined
let sessionAll: Entry[] | undefined
const sessions = (text: string) => {
const query = text.trim()
if (!query) {
sessionToken.value += 1
sessionInflight = undefined
sessionAll = undefined
return [] as Entry[]
}
if (sessionAll) return sessionAll
if (sessionInflight) return sessionInflight
const current = sessionToken.value
const dirs = workspaces()
if (dirs.length === 0) return [] as Entry[]
sessionInflight = Promise.all(
dirs.map((directory) => {
const description = label(directory)
return globalSDK.client.session
.list({ directory, roots: true })
.then((x) =>
(x.data ?? [])
.filter((s) => !!s?.id)
.map((s) => ({
id: s.id,
title: s.title ?? language.t("command.session.new"),
description,
directory,
archived: s.time?.archived,
updated: s.time?.updated,
})),
)
.catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[])
}),
)
.then((results) => {
if (sessionToken.value !== current) return [] as Entry[]
const seen = new Set<string>()
const next = results
.flat()
.filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map(sessionItem)
sessionAll = next
return next
})
.catch(() => [] as Entry[])
.finally(() => {
sessionInflight = undefined
})
return sessionInflight
}
const { sessions } = createSessionEntries({ workspaces, label, globalSDK, language })
const items = async (text: string) => {
const query = text.trim()
@@ -254,7 +310,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
if (!query && filesOnly()) {
const loaded = file.tree.state("")?.loaded
const pending = loaded ? Promise.resolve() : file.tree.list("")
const next = unique([...recent(), ...root()])
const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
if (loaded || next.length > 0) {
void pending
@@ -262,19 +318,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
}
await pending
return unique([...recent(), ...root()])
return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
}
if (!query) return [...picks(), ...recent()]
if (!query) return [...commandEntries.picks(), ...fileEntries.recent()]
if (filesOnly()) {
const files = await file.searchFiles(query)
return files.map(fileItem)
const category = language.t("palette.group.files")
return files.map((path) => createFileEntry(path, category))
}
const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
const entries = files.map(fileItem)
return [...list(), ...nextSessions, ...entries]
const category = language.t("palette.group.files")
const entries = files.map((path) => createFileEntry(path, category))
return [...commandEntries.list(), ...nextSessions, ...entries]
}
const handleMove = (item: Entry | undefined) => {
@@ -6,6 +6,13 @@ import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/context/language"
const statusLabels = {
connected: "mcp.status.connected",
failed: "mcp.status.failed",
needs_auth: "mcp.status.needs_auth",
disabled: "mcp.status.disabled",
} as const
export const DialogSelectMcp: Component = () => {
const sync = useSync()
const sdk = useSDK()
@@ -21,15 +28,19 @@ export const DialogSelectMcp: Component = () => {
const toggle = async (name: string) => {
if (loading()) return
setLoading(name)
const status = sync.data.mcp[name]
if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name })
} else {
await sdk.client.mcp.connect({ name })
try {
const status = sync.data.mcp[name]
if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name })
} else {
await sdk.client.mcp.connect({ name })
}
const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data)
} finally {
setLoading(null)
}
const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data)
setLoading(null)
}
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
@@ -54,6 +65,11 @@ export const DialogSelectMcp: Component = () => {
{(i) => {
const mcpStatus = () => sync.data.mcp[i.name]
const status = () => mcpStatus()?.status
const statusLabel = () => {
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
if (!key) return
return language.t(key)
}
const error = () => {
const s = mcpStatus()
return s?.status === "failed" ? s.error : undefined
@@ -64,17 +80,8 @@ export const DialogSelectMcp: Component = () => {
<div class="flex flex-col gap-0.5 min-w-0">
<div class="flex items-center gap-2">
<span class="truncate">{i.name}</span>
<Show when={status() === "connected"}>
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.connected")}</span>
</Show>
<Show when={status() === "failed"}>
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.failed")}</span>
</Show>
<Show when={status() === "needs_auth"}>
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.needs_auth")}</span>
</Show>
<Show when={status() === "disabled"}>
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.disabled")}</span>
<Show when={statusLabel()}>
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
</Show>
<Show when={loading() === i.name}>
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
@@ -6,7 +6,7 @@ import { List, type ListRef } from "@opencode-ai/ui/list"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tag } from "@opencode-ai/ui/tag"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { type Component, onCleanup, onMount, Show } from "solid-js"
import { type Component, Show } from "solid-js"
import { useLocal } from "@/context/local"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { DialogConnectProvider } from "./dialog-connect-provider"
@@ -21,24 +21,17 @@ export const DialogSelectModelUnpaid: Component = () => {
const language = useLanguage()
let listRef: ListRef | undefined
const handleKey = (e: KeyboardEvent) => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") return
listRef?.onKeyDown(e)
}
onMount(() => {
document.addEventListener("keydown", handleKey)
onCleanup(() => {
document.removeEventListener("keydown", handleKey)
})
})
return (
<Dialog
title={language.t("dialog.model.select.title")}
class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none"
>
<div class="flex flex-col gap-3 px-2.5">
<div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}>
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
<List
class="[&_[data-slot=list-scroll]]:overflow-visible"
@@ -1,5 +1,5 @@
import { Popover as Kobalte } from "@kobalte/core/popover"
import { Component, ComponentProps, createEffect, createMemo, JSX, onCleanup, Show, ValidComponent } from "solid-js"
import { Component, ComponentProps, createMemo, JSX, Show, ValidComponent } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -15,6 +15,9 @@ import { DialogManageModels } from "./dialog-manage-models"
import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
const ModelList: Component<{
provider?: string
class?: string
@@ -54,13 +57,7 @@ const ModelList: Component<{
class="w-full"
placement="right-start"
gutter={12}
value={
<ModelTooltip
model={item}
latest={item.latest}
free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)}
/>
}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
>
{node}
</Tooltip>
@@ -75,7 +72,7 @@ const ModelList: Component<{
{(i) => (
<div class="w-full flex items-center gap-x-2 text-13-regular">
<span class="truncate">{i.name}</span>
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
<Show when={isFree(i.provider.id, i.cost)}>
<Tag>{language.t("model.tag.free")}</Tag>
</Show>
<Show when={i.latest}>
@@ -98,13 +95,9 @@ export function ModelSelectorPopover(props: {
const [store, setStore] = createStore<{
open: boolean
dismiss: "escape" | "outside" | null
trigger?: HTMLElement
content?: HTMLElement
}>({
open: false,
dismiss: null,
trigger: undefined,
content: undefined,
})
const dialog = useDialog()
@@ -119,54 +112,6 @@ export function ModelSelectorPopover(props: {
}
const language = useLanguage()
createEffect(() => {
if (!store.open) return
const inside = (node: Node | null | undefined) => {
if (!node) return false
const el = store.content
if (el && el.contains(node)) return true
const anchor = store.trigger
if (anchor && anchor.contains(node)) return true
return false
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return
setStore("dismiss", "escape")
setStore("open", false)
event.preventDefault()
event.stopPropagation()
}
const onPointerDown = (event: PointerEvent) => {
const target = event.target
if (!(target instanceof Node)) return
if (inside(target)) return
setStore("dismiss", "outside")
setStore("open", false)
}
const onFocusIn = (event: FocusEvent) => {
if (!store.content) return
const target = event.target
if (!(target instanceof Node)) return
if (inside(target)) return
setStore("dismiss", "outside")
setStore("open", false)
}
window.addEventListener("keydown", onKeyDown, true)
window.addEventListener("pointerdown", onPointerDown, true)
window.addEventListener("focusin", onFocusIn, true)
onCleanup(() => {
window.removeEventListener("keydown", onKeyDown, true)
window.removeEventListener("pointerdown", onPointerDown, true)
window.removeEventListener("focusin", onFocusIn, true)
})
})
return (
<Kobalte
open={store.open}
@@ -178,12 +123,11 @@ export function ModelSelectorPopover(props: {
placement="top-start"
gutter={8}
>
<Kobalte.Trigger ref={(el) => setStore("trigger", el)} as={props.triggerAs ?? "div"} {...props.triggerProps}>
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
{props.children}
</Kobalte.Trigger>
<Kobalte.Portal>
<Kobalte.Content
ref={(el) => setStore("content", el)}
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
onEscapeKeyDown={(event) => {
setStore("dismiss", "escape")
@@ -24,6 +24,12 @@ export const DialogSelectProvider: Component = () => {
const popularGroup = () => language.t("dialog.provider.group.popular")
const otherGroup = () => language.t("dialog.provider.group.other")
const customLabel = () => language.t("settings.providers.tag.custom")
const note = (id: string) => {
if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
if (id === "openai") return language.t("dialog.provider.openai.note")
if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
}
return (
<Dialog title={language.t("command.provider.connect")} transition>
@@ -34,7 +40,7 @@ export const DialogSelectProvider: Component = () => {
key={(x) => x?.id}
items={() => {
language.locale()
return [{ id: CUSTOM_ID, name: "Custom provider" }, ...providers.all()]
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all()]
}}
filterKeys={["id", "name"]}
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
@@ -70,15 +76,7 @@ export const DialogSelectProvider: Component = () => {
<Show when={i.id === "opencode"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={i.id === "anthropic"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
</Show>
<Show when={i.id === "openai"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.openai.note")}</div>
</Show>
<Show when={i.id.startsWith("github-copilot")}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.copilot.note")}</div>
</Show>
<Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
</div>
)}
</List>
@@ -38,6 +38,64 @@ interface EditRowProps {
onBlur: () => void
}
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
const [defaultUrl, defaultUrlActions] = createResource(
async () => {
try {
const url = await platform.getDefaultServerUrl?.()
if (!url) return null
return normalizeServerUrl(url) ?? null
} catch (err) {
showRequestError(language, err)
return null
}
},
{ initialValue: null },
)
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
const setDefault = async (url: string | null) => {
try {
await platform.setDefaultServerUrl?.(url)
defaultUrlActions.mutate(url)
} catch (err) {
showRequestError(language, err)
}
}
return { defaultUrl, canDefault, setDefault }
}
function useServerPreview(fetcher: typeof fetch) {
const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) return false
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
if (!host) return false
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
return host.includes(".") || host.includes(":")
}
const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
setStatus(undefined)
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
const result = await checkServerHealth(normalized, fetcher)
setStatus(result.healthy)
}
return { previewStatus }
}
function AddRow(props: AddRowProps) {
return (
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
@@ -115,6 +173,10 @@ export function DialogSelectServer() {
const platform = usePlatform()
const globalSDK = useGlobalSDK()
const language = useLanguage()
const fetcher = platform.fetch ?? globalThis.fetch
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
const { previewStatus } = useServerPreview(fetcher)
let listRoot: HTMLDivElement | undefined
const [store, setStore] = createStore({
status: {} as Record<string, ServerHealth | undefined>,
addServer: {
@@ -132,43 +194,6 @@ export function DialogSelectServer() {
status: undefined as boolean | undefined,
},
})
const [defaultUrl, defaultUrlActions] = createResource(
async () => {
try {
const url = await platform.getDefaultServerUrl?.()
if (!url) return null
return normalizeServerUrl(url) ?? null
} catch (err) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
return null
}
},
{ initialValue: null },
)
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
const fetcher = platform.fetch ?? globalThis.fetch
const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) return false
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
if (!host) return false
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
return host.includes(".") || host.includes(":")
}
const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
setStatus(undefined)
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
const result = await checkServerHealth(normalized, fetcher)
setStatus(result.healthy)
}
const resetAdd = () => {
setStore("addServer", {
@@ -263,7 +288,7 @@ export function DialogSelectServer() {
}
const scrollListToBottom = () => {
const scroll = document.querySelector<HTMLDivElement>('[data-component="list"] [data-slot="list-scroll"]')
const scroll = listRoot?.querySelector<HTMLDivElement>('[data-slot="list-scroll"]')
if (!scroll) return
requestAnimationFrame(() => {
scroll.scrollTop = scroll.scrollHeight
@@ -363,158 +388,134 @@ export function DialogSelectServer() {
return (
<Dialog title={language.t("dialog.server.title")}>
<div class="flex flex-col gap-2">
<List
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={sortedItems}
key={(x) => x}
onSelect={(x) => {
if (x) select(x)
}}
onFilter={(value) => {
if (value && store.addServer.showForm && !store.addServer.adding) {
resetAdd()
}
}}
divider={true}
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
add={
store.addServer.showForm
? {
render: () => (
<AddRow
value={store.addServer.url}
placeholder={language.t("dialog.server.add.placeholder")}
adding={store.addServer.adding}
error={store.addServer.error}
status={store.addServer.status}
onChange={handleAddChange}
onKeyDown={handleAddKey}
onBlur={blurAdd}
/>
),
}
: undefined
}
>
{(i) => {
return (
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
<Show
when={store.editServer.id !== i}
fallback={
<EditRow
value={store.editServer.value}
placeholder={language.t("dialog.server.add.placeholder")}
busy={store.editServer.busy}
error={store.editServer.error}
status={store.editServer.status}
onChange={handleEditChange}
onKeyDown={(event) => handleEditKey(event, i)}
onBlur={() => handleEdit(i, store.editServer.value)}
/>
}
>
<ServerRow
url={i}
status={store.status[i]}
dimmed={store.status[i]?.healthy === false}
class="flex items-center gap-3 px-4 min-w-0 flex-1"
badge={
<Show when={defaultUrl() === i}>
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
}
/>
</Show>
<Show when={store.editServer.id !== i}>
<div class="flex items-center justify-center gap-5 pl-4">
<Show when={current() === i}>
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
</Show>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
<div ref={(el) => (listRoot = el)}>
<List
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={sortedItems}
key={(x) => x}
onSelect={(x) => {
if (x) select(x)
}}
onFilter={(value) => {
if (value && store.addServer.showForm && !store.addServer.adding) {
resetAdd()
}
}}
divider={true}
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
add={
store.addServer.showForm
? {
render: () => (
<AddRow
value={store.addServer.url}
placeholder={language.t("dialog.server.add.placeholder")}
adding={store.addServer.adding}
error={store.addServer.error}
status={store.addServer.status}
onChange={handleAddChange}
onKeyDown={handleAddKey}
onBlur={blurAdd}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
setStore("editServer", {
id: i,
value: i,
error: "",
status: store.status[i]?.healthy,
})
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultUrl() !== i}>
),
}
: undefined
}
>
{(i) => {
return (
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
<Show
when={store.editServer.id !== i}
fallback={
<EditRow
value={store.editServer.value}
placeholder={language.t("dialog.server.add.placeholder")}
busy={store.editServer.busy}
error={store.editServer.error}
status={store.editServer.status}
onChange={handleEditChange}
onKeyDown={(event) => handleEditKey(event, i)}
onBlur={() => handleEdit(i, store.editServer.value)}
/>
}
>
<ServerRow
url={i}
status={store.status[i]}
dimmed={store.status[i]?.healthy === false}
class="flex items-center gap-3 px-4 min-w-0 flex-1"
badge={
<Show when={defaultUrl() === i}>
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
}
/>
</Show>
<Show when={store.editServer.id !== i}>
<div class="flex items-center justify-center gap-5 pl-4">
<Show when={current() === i}>
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
</Show>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={async () => {
try {
await platform.setDefaultServerUrl?.(i)
defaultUrlActions.mutate(i)
} catch (err) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
onSelect={() => {
setStore("editServer", {
id: i,
value: i,
error: "",
status: store.status[i]?.healthy,
})
}}
>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultUrl() === i}>
<Show when={canDefault() && defaultUrl() !== i}>
<DropdownMenu.Item onSelect={() => setDefault(i)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultUrl() === i}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={async () => {
try {
await platform.setDefaultServerUrl?.(null)
defaultUrlActions.mutate(null)
} catch (err) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
}}
onSelect={() => handleRemove(i)}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(i)}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</Show>
</div>
)
}}
</List>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</Show>
</div>
)
}}
</List>
</div>
<div class="px-5 pb-5">
<Button
@@ -67,15 +67,6 @@ export const DialogSettings: Component = () => {
<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>
)
+212 -174
View File
@@ -15,6 +15,7 @@ import {
Switch,
untrack,
type ComponentProps,
type JSXElement,
type ParentProps,
} from "solid-js"
import { Dynamic } from "solid-js/web"
@@ -59,6 +60,189 @@ export function dirsToExpand(input: {
return [...input.filter.dirs].filter((dir) => !input.expanded(dir))
}
const kindLabel = (kind: Kind) => {
if (kind === "add") return "A"
if (kind === "del") return "D"
return "M"
}
const kindTextColor = (kind: Kind) => {
if (kind === "add") return "color: var(--icon-diff-add-base)"
if (kind === "del") return "color: var(--icon-diff-delete-base)"
return "color: var(--icon-warning-active)"
}
const kindDotColor = (kind: Kind) => {
if (kind === "add") return "background-color: var(--icon-diff-add-base)"
if (kind === "del") return "background-color: var(--icon-diff-delete-base)"
return "background-color: var(--icon-warning-active)"
}
const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
const kind = kinds?.get(node.path)
if (!kind) return
if (!marks?.has(node.path)) return
return kind
}
const buildDragImage = (target: HTMLElement) => {
const icon = target.querySelector('[data-component="file-icon"]') ?? target.querySelector("svg")
const text = target.querySelector("span")
if (!icon || !text) return
const image = document.createElement("div")
image.className =
"flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong"
image.style.position = "absolute"
image.style.top = "-1000px"
image.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML
return image
}
const withFileDragImage = (event: DragEvent) => {
const image = buildDragImage(event.currentTarget as HTMLElement)
if (!image) return
document.body.appendChild(image)
event.dataTransfer?.setDragImage(image, 0, 12)
setTimeout(() => document.body.removeChild(image), 0)
}
const FileTreeNode = (
p: ParentProps &
ComponentProps<"div"> &
ComponentProps<"button"> & {
node: FileNode
level: number
active?: string
nodeClass?: string
draggable: boolean
kinds?: ReadonlyMap<string, Kind>
marks?: Set<string>
as?: "div" | "button"
},
) => {
const [local, rest] = splitProps(p, [
"node",
"level",
"active",
"nodeClass",
"draggable",
"kinds",
"marks",
"as",
"children",
"class",
"classList",
])
const kind = () => visibleKind(local.node, local.kinds, local.marks)
const active = () => !!kind() && !local.node.ignored
const color = () => {
const value = kind()
if (!value) return
return kindTextColor(value)
}
return (
<Dynamic
component={local.as ?? "div"}
classList={{
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
"bg-surface-base-active": local.node.path === local.active,
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
[local.nodeClass ?? ""]: !!local.nodeClass,
}}
style={`padding-left: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
draggable={local.draggable}
onDragStart={(event: DragEvent) => {
if (!local.draggable) return
event.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy"
withFileDragImage(event)
}}
{...rest}
>
{local.children}
<span
classList={{
"flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true,
"text-text-weaker": local.node.ignored,
"text-text-weak": !local.node.ignored && !active(),
}}
style={active() ? color() : undefined}
>
{local.node.name}
</span>
{(() => {
const value = kind()
if (!value) return null
if (local.node.type === "file") {
return (
<span class="shrink-0 w-4 text-center text-12-medium" style={kindTextColor(value)}>
{kindLabel(value)}
</span>
)
}
return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={kindDotColor(value)} />
})()}
</Dynamic>
)
}
const FileTreeNodeTooltip = (props: { enabled: boolean; node: FileNode; kind?: Kind; children: JSXElement }) => {
if (!props.enabled) return props.children
const parts = props.node.path.split("/")
const leaf = parts[parts.length - 1] ?? props.node.path
const head = parts.slice(0, -1).join("/")
const prefix = head ? `${head}/` : ""
const label =
props.kind === "add"
? "Additions"
: props.kind === "del"
? "Deletions"
: props.kind === "mix"
? "Modifications"
: undefined
return (
<Tooltip
openDelay={2000}
placement="bottom-start"
class="w-full"
contentStyle={{ "max-width": "480px", width: "fit-content" }}
value={
<div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
<span
class="min-w-0 truncate text-text-invert-base"
style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
>
{prefix}
</span>
<span class="shrink-0 text-text-invert-strong">{leaf}</span>
<Show when={label}>
{(text) => (
<>
<span class="mx-1 font-bold text-text-invert-strong"></span>
<span class="shrink-0 text-text-invert-strong">{text()}</span>
</>
)}
</Show>
<Show when={props.node.type === "directory" && props.node.ignored}>
<>
<span class="mx-1 font-bold text-text-invert-strong"></span>
<span class="shrink-0 text-text-invert-strong">Ignored</span>
</>
</Show>
</div>
}
>
{props.children}
</Tooltip>
)
}
export default function FileTree(props: {
path: string
class?: string
@@ -230,178 +414,13 @@ export default function FileTree(props: {
return out
})
const Node = (
p: ParentProps &
ComponentProps<"div"> &
ComponentProps<"button"> & {
node: FileNode
as?: "div" | "button"
},
) => {
const [local, rest] = splitProps(p, ["node", "as", "children", "class", "classList"])
return (
<Dynamic
component={local.as ?? "div"}
classList={{
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
"bg-surface-base-active": local.node.path === props.active,
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
[props.nodeClass ?? ""]: !!props.nodeClass,
}}
style={`padding-left: ${Math.max(0, 8 + level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
draggable={draggable()}
onDragStart={(e: DragEvent) => {
if (!draggable()) return
e.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
e.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"
const dragImage = document.createElement("div")
dragImage.className =
"flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong"
dragImage.style.position = "absolute"
dragImage.style.top = "-1000px"
const icon =
(e.currentTarget as HTMLElement).querySelector('[data-component="file-icon"]') ??
(e.currentTarget as HTMLElement).querySelector("svg")
const text = (e.currentTarget as HTMLElement).querySelector("span")
if (icon && text) {
dragImage.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML
}
document.body.appendChild(dragImage)
e.dataTransfer?.setDragImage(dragImage, 0, 12)
setTimeout(() => document.body.removeChild(dragImage), 0)
}}
{...rest}
>
{local.children}
{(() => {
const kind = kinds()?.get(local.node.path)
const marked = marks()?.has(local.node.path) ?? false
const active = !!kind && marked && !local.node.ignored
const color =
kind === "add"
? "color: var(--icon-diff-add-base)"
: kind === "del"
? "color: var(--icon-diff-delete-base)"
: kind === "mix"
? "color: var(--icon-warning-active)"
: undefined
return (
<span
classList={{
"flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true,
"text-text-weaker": local.node.ignored,
"text-text-weak": !local.node.ignored && !active,
}}
style={active ? color : undefined}
>
{local.node.name}
</span>
)
})()}
{(() => {
const kind = kinds()?.get(local.node.path)
if (!kind) return null
if (!marks()?.has(local.node.path)) return null
if (local.node.type === "file") {
const text = kind === "add" ? "A" : kind === "del" ? "D" : "M"
const color =
kind === "add"
? "color: var(--icon-diff-add-base)"
: kind === "del"
? "color: var(--icon-diff-delete-base)"
: "color: var(--icon-warning-active)"
return (
<span class="shrink-0 w-4 text-center text-12-medium" style={color}>
{text}
</span>
)
}
if (local.node.type === "directory") {
const color =
kind === "add"
? "background-color: var(--icon-diff-add-base)"
: kind === "del"
? "background-color: var(--icon-diff-delete-base)"
: "background-color: var(--icon-warning-active)"
return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={color} />
}
return null
})()}
</Dynamic>
)
}
return (
<div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
<For each={nodes()}>
{(node) => {
const expanded = () => file.tree.state(node.path)?.expanded ?? false
const deep = () => deeps().get(node.path) ?? -1
const Wrapper = (p: ParentProps) => {
if (!tooltip()) return p.children
const parts = node.path.split("/")
const leaf = parts[parts.length - 1] ?? node.path
const head = parts.slice(0, -1).join("/")
const prefix = head ? `${head}/` : ""
const kind = () => kinds()?.get(node.path)
const label = () => {
const k = kind()
if (!k) return
if (k === "add") return "Additions"
if (k === "del") return "Deletions"
return "Modifications"
}
const ignored = () => node.type === "directory" && node.ignored
return (
<Tooltip
openDelay={2000}
placement="bottom-start"
class="w-full"
contentStyle={{ "max-width": "480px", width: "fit-content" }}
value={
<div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
<span
class="min-w-0 truncate text-text-invert-base"
style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
>
{prefix}
</span>
<span class="shrink-0 text-text-invert-strong">{leaf}</span>
<Show when={label()}>
{(t: () => string) => (
<>
<span class="mx-1 font-bold text-text-invert-strong"></span>
<span class="shrink-0 text-text-invert-strong">{t()}</span>
</>
)}
</Show>
<Show when={ignored()}>
<>
<span class="mx-1 font-bold text-text-invert-strong"></span>
<span class="shrink-0 text-text-invert-strong">Ignored</span>
</>
</Show>
</div>
}
>
{p.children}
</Tooltip>
)
}
const kind = () => visibleKind(node, kinds(), marks())
return (
<Switch>
@@ -415,13 +434,21 @@ export default function FileTree(props: {
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
>
<Collapsible.Trigger>
<Wrapper>
<Node node={node}>
<FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
<FileTreeNode
node={node}
level={level}
active={props.active}
nodeClass={props.nodeClass}
draggable={draggable()}
kinds={kinds()}
marks={marks()}
>
<div class="size-4 flex items-center justify-center text-icon-weak">
<Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
</div>
</Node>
</Wrapper>
</FileTreeNode>
</FileTreeNodeTooltip>
</Collapsible.Trigger>
<Collapsible.Content class="relative pt-0.5">
<div
@@ -451,12 +478,23 @@ export default function FileTree(props: {
</Collapsible>
</Match>
<Match when={node.type === "file"}>
<Wrapper>
<Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}>
<FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
<FileTreeNode
node={node}
level={level}
active={props.active}
nodeClass={props.nodeClass}
draggable={draggable()}
kinds={kinds()}
marks={marks()}
as="button"
type="button"
onClick={() => props.onFileClick?.(node)}
>
<div class="w-4 shrink-0" />
<FileIcon node={node} class="text-icon-weak size-4" />
</Node>
</Wrapper>
</FileTreeNode>
</FileTreeNodeTooltip>
</Match>
</Switch>
)
+13 -4
View File
@@ -1,17 +1,26 @@
import { ComponentProps, splitProps } from "solid-js"
import { usePlatform } from "@/context/platform"
export interface LinkProps extends ComponentProps<"button"> {
export interface LinkProps extends Omit<ComponentProps<"a">, "href"> {
href: string
}
export function Link(props: LinkProps) {
const platform = usePlatform()
const [local, rest] = splitProps(props, ["href", "children"])
const [local, rest] = splitProps(props, ["href", "children", "class"])
return (
<button class="text-text-strong underline" onClick={() => platform.openLink(local.href)} {...rest}>
<a
href={local.href}
class={`text-text-strong underline ${local.class ?? ""}`}
onClick={(event) => {
if (!local.href) return
event.preventDefault()
platform.openLink(local.href)
}}
{...rest}
>
{local.children}
</button>
</a>
)
}
+60 -54
View File
@@ -277,6 +277,47 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isFocused = createFocusSignal(() => editorRef)
const closePopover = () => setStore("popover", null)
const resetHistoryNavigation = (force = false) => {
if (!force && (store.historyIndex < 0 || store.applyingHistory)) return
setStore("historyIndex", -1)
setStore("savedPrompt", null)
}
const clearEditor = () => {
editorRef.innerHTML = ""
}
const setEditorText = (text: string) => {
clearEditor()
editorRef.textContent = text
}
const focusEditorEnd = () => {
requestAnimationFrame(() => {
editorRef.focus()
const range = document.createRange()
const selection = window.getSelection()
range.selectNodeContents(editorRef)
range.collapse(false)
selection?.removeAllRanges()
selection?.addRange(range)
})
}
const currentCursor = () => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) return null
return getCursorPosition(editorRef)
}
const renderEditorWithCursor = (parts: Prompt) => {
const cursor = currentCursor()
renderEditor(parts)
if (cursor !== null) setCursorPosition(editorRef, cursor)
}
createEffect(() => {
params.id
if (params.id) return
@@ -290,7 +331,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
createEffect(() => {
if (!isFocused()) setStore("popover", null)
if (!isFocused()) closePopover()
})
// Safety: reset composing state on focus change to prevent stuck state
@@ -304,6 +345,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
.filter((agent) => !agent.hidden && agent.mode !== "primary")
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
)
const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name))
const handleAtSelect = (option: AtOption | undefined) => {
if (!option) return
@@ -381,26 +423,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
if (!cmd) return
setStore("popover", null)
closePopover()
if (cmd.type === "custom") {
const text = `/${cmd.trigger} `
editorRef.innerHTML = ""
editorRef.textContent = text
setEditorText(text)
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
requestAnimationFrame(() => {
editorRef.focus()
const range = document.createRange()
const sel = window.getSelection()
range.selectNodeContents(editorRef)
range.collapse(false)
sel?.removeAllRanges()
sel?.addRange(range)
})
focusEditorEnd()
return
}
editorRef.innerHTML = ""
clearEditor()
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
command.trigger(cmd.id, "slash")
}
@@ -454,7 +487,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
const renderEditor = (parts: Prompt) => {
editorRef.innerHTML = ""
clearEditor()
for (const part of parts) {
if (part.type === "text") {
editorRef.appendChild(createTextFragment(part.content))
@@ -514,34 +547,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
mirror.input = false
if (isNormalizedEditor()) return
const selection = window.getSelection()
let cursorPosition: number | null = null
if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) {
cursorPosition = getCursorPosition(editorRef)
}
renderEditor(inputParts)
if (cursorPosition !== null) {
setCursorPosition(editorRef, cursorPosition)
}
renderEditorWithCursor(inputParts)
return
}
const domParts = parseFromDOM()
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
const selection = window.getSelection()
let cursorPosition: number | null = null
if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) {
cursorPosition = getCursorPosition(editorRef)
}
renderEditor(inputParts)
if (cursorPosition !== null) {
setCursorPosition(editorRef, cursorPosition)
}
renderEditorWithCursor(inputParts)
},
),
)
@@ -636,11 +649,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
if (shouldReset) {
setStore("popover", null)
if (store.historyIndex >= 0 && !store.applyingHistory) {
setStore("historyIndex", -1)
setStore("savedPrompt", null)
}
closePopover()
resetHistoryNavigation()
if (prompt.dirty()) {
mirror.input = true
prompt.set(DEFAULT_PROMPT, 0)
@@ -662,16 +672,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
slashOnInput(slashMatch[1])
setStore("popover", "slash")
} else {
setStore("popover", null)
closePopover()
}
} else {
setStore("popover", null)
closePopover()
}
if (store.historyIndex >= 0 && !store.applyingHistory) {
setStore("historyIndex", -1)
setStore("savedPrompt", null)
}
resetHistoryNavigation()
mirror.input = true
prompt.set([...rawParts, ...images], cursorPosition)
@@ -732,7 +739,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
handleInput()
setStore("popover", null)
closePopover()
}
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
@@ -782,8 +789,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
promptLength,
addToHistory,
resetHistoryNavigation: () => {
setStore("historyIndex", -1)
setStore("savedPrompt", null)
resetHistoryNavigation(true)
},
setMode: (mode) => setStore("mode", mode),
setPopover: (popover) => setStore("popover", popover),
@@ -872,7 +878,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (ctrl && event.code === "KeyG") {
if (store.popover) {
setStore("popover", null)
closePopover()
event.preventDefault()
return
}
@@ -923,7 +929,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
if (event.key === "Escape") {
if (store.popover) {
setStore("popover", null)
closePopover()
} else if (working()) {
abort()
}
@@ -1033,7 +1039,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
keybind={command.keybind("agent.cycle")}
>
<Select
options={local.agent.list().map((agent) => agent.name)}
options={agentNames()}
current={local.agent.current()?.name ?? ""}
onSelect={local.agent.set}
class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-full" : "max-w-[120px]"}`}
@@ -2,6 +2,7 @@ import { onCleanup, onMount } from "solid-js"
import { showToast } from "@opencode-ai/ui/toast"
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
import { useLanguage } from "@/context/language"
import { uuid } from "@/utils/uuid"
import { getCursorPosition } from "./editor-dom"
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
@@ -31,7 +32,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const dataUrl = reader.result as string
const attachment: ImageAttachmentPart = {
type: "image",
id: crypto.randomUUID?.() ?? Math.random().toString(16).slice(2),
id: uuid(),
filename: file.name,
mime: file.type,
dataUrl,
@@ -88,6 +89,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
}
if (!plainText) return
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
if (inserted) return
input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
}
@@ -20,61 +20,68 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
<Show when={props.items.length > 0}>
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
<For each={props.items}>
{(item) => (
<Tooltip
value={
<span class="flex max-w-[300px]">
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
{getDirectory(item.path)}
{(item) => {
const directory = getDirectory(item.path)
const filename = getFilename(item.path)
const label = getFilenameTruncated(item.path, 14)
const selected = props.active(item)
return (
<Tooltip
value={
<span class="flex max-w-[300px]">
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
{directory}
</span>
<span class="shrink-0">{filename}</span>
</span>
<span class="shrink-0">{getFilename(item.path)}</span>
</span>
}
placement="top"
openDelay={2000}
>
<div
classList={{
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !props.active(item),
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
props.active(item),
"bg-background-stronger": !props.active(item),
}}
onClick={() => props.openComment(item)}
}
placement="top"
openDelay={2000}
>
<div class="flex items-center gap-1.5">
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
<div class="flex items-center text-11-regular min-w-0 font-medium">
<span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
<Show when={item.selection}>
{(sel) => (
<span class="text-text-weak whitespace-nowrap shrink-0">
{sel().startLine === sel().endLine
? `:${sel().startLine}`
: `:${sel().startLine}-${sel().endLine}`}
</span>
)}
</Show>
<div
classList={{
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !selected,
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
selected,
"bg-background-stronger": !selected,
}}
onClick={() => props.openComment(item)}
>
<div class="flex items-center gap-1.5">
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
<div class="flex items-center text-11-regular min-w-0 font-medium">
<span class="text-text-strong whitespace-nowrap">{label}</span>
<Show when={item.selection}>
{(sel) => (
<span class="text-text-weak whitespace-nowrap shrink-0">
{sel().startLine === sel().endLine
? `:${sel().startLine}`
: `:${sel().startLine}-${sel().endLine}`}
</span>
)}
</Show>
</div>
<IconButton
type="button"
icon="close-small"
variant="ghost"
class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
onClick={(e) => {
e.stopPropagation()
props.remove(item)
}}
aria-label={props.t("prompt.context.removeFile")}
/>
</div>
<IconButton
type="button"
icon="close-small"
variant="ghost"
class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
onClick={(e) => {
e.stopPropagation()
props.remove(item)
}}
aria-label={props.t("prompt.context.removeFile")}
/>
<Show when={item.comment}>
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
</Show>
</div>
<Show when={item.comment}>
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
</Show>
</div>
</Tooltip>
)}
</Tooltip>
)
}}
</For>
</div>
</Show>
@@ -6,12 +6,17 @@ type PromptDragOverlayProps = {
label: string
}
const kindToIcon = {
image: "photo",
"@mention": "link",
} as const
export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => {
return (
<Show when={props.type !== null}>
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
<div class="flex flex-col items-center gap-2 text-text-weak">
<Icon name={props.type === "@mention" ? "link" : "photo"} class="size-8" />
<Icon name={props.type ? kindToIcon[props.type] : kindToIcon.image} class="size-8" />
<span class="text-14-regular">{props.label}</span>
</div>
</div>
@@ -9,6 +9,13 @@ type PromptImageAttachmentsProps = {
removeLabel: string
}
const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
const imageClass =
"size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
const removeClass =
"absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
return (
<Show when={props.attachments.length > 0}>
@@ -19,7 +26,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
<Show
when={attachment.mime.startsWith("image/")}
fallback={
<div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
<div class={fallbackClass}>
<Icon name="folder" class="size-6 text-text-weak" />
</div>
}
@@ -27,19 +34,19 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
<img
src={attachment.dataUrl}
alt={attachment.filename}
class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
class={imageClass}
onClick={() => props.onOpen(attachment)}
/>
</Show>
<button
type="button"
onClick={() => props.onRemove(attachment.id)}
class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
class={removeClass}
aria-label={props.removeLabel}
>
<Icon name="close" class="size-3 text-text-weak" />
</button>
<div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
<div class={nameClass}>
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
</div>
</div>
@@ -52,47 +52,46 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
>
<For each={props.atFlat.slice(0, 10)}>
{(item) => (
<button
classList={{
"w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
"bg-surface-raised-base-hover": props.atActive === props.atKey(item),
}}
onClick={() => props.onAtSelect(item)}
onMouseEnter={() => props.setAtActive(props.atKey(item))}
>
<Show
when={item.type === "agent"}
fallback={
<>
<FileIcon
node={{ path: item.type === "file" ? item.path : "", type: "file" }}
class="shrink-0 size-4"
/>
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">
{item.type === "file"
? item.path.endsWith("/")
? item.path
: getDirectory(item.path)
: ""}
</span>
<Show when={item.type === "file" && !item.path.endsWith("/")}>
<span class="text-text-strong whitespace-nowrap">
{item.type === "file" ? getFilename(item.path) : ""}
</span>
</Show>
</div>
</>
}
{(item) => {
const active = props.atActive === props.atKey(item)
const shared = {
"w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
"bg-surface-raised-base-hover": active,
}
if (item.type === "agent") {
return (
<button
classList={shared}
onClick={() => props.onAtSelect(item)}
onMouseEnter={() => props.setAtActive(props.atKey(item))}
>
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
<span class="text-14-regular text-text-strong whitespace-nowrap">@{item.name}</span>
</button>
)
}
const isDirectory = item.path.endsWith("/")
const directory = isDirectory ? item.path : getDirectory(item.path)
const filename = isDirectory ? "" : getFilename(item.path)
return (
<button
classList={shared}
onClick={() => props.onAtSelect(item)}
onMouseEnter={() => props.setAtActive(props.atKey(item))}
>
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
<span class="text-14-regular text-text-strong whitespace-nowrap">
@{item.type === "agent" ? item.name : ""}
</span>
</Show>
</button>
)}
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{directory}</span>
<Show when={!isDirectory}>
<span class="text-text-strong whitespace-nowrap">{filename}</span>
</Show>
</div>
</button>
)
}}
</For>
</Show>
</Match>
+29 -37
View File
@@ -38,43 +38,45 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
showToast({ title: language.t("common.requestFailed"), description: message })
}
const reply = (answers: QuestionAnswer[]) => {
const reply = async (answers: QuestionAnswer[]) => {
if (store.sending) return
setStore("sending", true)
sdk.client.question
.reply({ requestID: props.request.id, answers })
.catch(fail)
.finally(() => setStore("sending", false))
try {
await sdk.client.question.reply({ requestID: props.request.id, answers })
} catch (err) {
fail(err)
} finally {
setStore("sending", false)
}
}
const reject = () => {
const reject = async () => {
if (store.sending) return
setStore("sending", true)
sdk.client.question
.reject({ requestID: props.request.id })
.catch(fail)
.finally(() => setStore("sending", false))
try {
await sdk.client.question.reject({ requestID: props.request.id })
} catch (err) {
fail(err)
} finally {
setStore("sending", false)
}
}
const submit = () => {
reply(questions().map((_, i) => store.answers[i] ?? []))
void reply(questions().map((_, i) => store.answers[i] ?? []))
}
const pick = (answer: string, custom: boolean = false) => {
const answers = [...store.answers]
answers[store.tab] = [answer]
setStore("answers", answers)
setStore("answers", store.tab, [answer])
if (custom) {
const inputs = [...store.custom]
inputs[store.tab] = answer
setStore("custom", inputs)
setStore("custom", store.tab, answer)
}
if (single()) {
reply([[answer]])
void reply([[answer]])
return
}
@@ -82,15 +84,10 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
}
const toggle = (answer: string) => {
const existing = store.answers[store.tab] ?? []
const next = [...existing]
const index = next.indexOf(answer)
if (index === -1) next.push(answer)
if (index !== -1) next.splice(index, 1)
const answers = [...store.answers]
answers[store.tab] = next
setStore("answers", answers)
setStore("answers", store.tab, (current = []) => {
if (current.includes(answer)) return current.filter((item) => item !== answer)
return [...current, answer]
})
}
const selectTab = (index: number) => {
@@ -126,13 +123,10 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
}
if (multi()) {
const existing = store.answers[store.tab] ?? []
const next = [...existing]
if (!next.includes(value)) next.push(value)
const answers = [...store.answers]
answers[store.tab] = next
setStore("answers", answers)
setStore("answers", store.tab, (current = []) => {
if (current.includes(value)) return current
return [...current, value]
})
setStore("editing", false)
return
}
@@ -225,9 +219,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
value={input()}
disabled={store.sending}
onInput={(e) => {
const inputs = [...store.custom]
inputs[store.tab] = e.currentTarget.value
setStore("custom", inputs)
setStore("custom", store.tab, e.currentTarget.value)
}}
/>
<Button type="submit" variant="primary" size="small" disabled={store.sending}>
@@ -1,5 +1,5 @@
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { JSXElement, ParentProps, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
import { JSXElement, ParentProps, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { serverDisplayName } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health"
@@ -17,6 +17,7 @@ export function ServerRow(props: ServerRowProps) {
const [truncated, setTruncated] = createSignal(false)
let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined
const name = createMemo(() => serverDisplayName(props.url))
const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
@@ -25,25 +26,24 @@ export function ServerRow(props: ServerRowProps) {
}
createEffect(() => {
name()
props.url
props.status?.version
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(check)
return
}
check()
queueMicrotask(check)
})
onMount(() => {
check()
if (typeof window === "undefined") return
window.addEventListener("resize", check)
onCleanup(() => window.removeEventListener("resize", check))
if (typeof ResizeObserver !== "function") return
const observer = new ResizeObserver(check)
if (nameRef) observer.observe(nameRef)
if (versionRef) observer.observe(versionRef)
onCleanup(() => observer.disconnect())
})
const tooltipValue = () => (
<span class="flex items-center gap-2">
<span>{serverDisplayName(props.url)}</span>
<span>{name()}</span>
<Show when={props.status?.version}>
<span class="text-text-invert-base">{props.status?.version}</span>
</Show>
@@ -62,7 +62,7 @@ export function ServerRow(props: ServerRowProps) {
}}
/>
<span ref={nameRef} class={props.nameClass ?? "truncate"}>
{serverDisplayName(props.url)}
{name()}
</span>
<Show when={props.status?.version}>
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
@@ -13,6 +13,18 @@ interface SessionContextUsageProps {
variant?: "button" | "indicator"
}
function openSessionContext(args: {
view: ReturnType<ReturnType<typeof useLayout>["view"]>
layout: ReturnType<typeof useLayout>
tabs: ReturnType<ReturnType<typeof useLayout>["tabs"]>
}) {
if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open()
args.layout.fileTree.open()
args.layout.fileTree.setTab("all")
args.tabs.open("context")
args.tabs.setActive("context")
}
export function SessionContextUsage(props: SessionContextUsageProps) {
const sync = useSync()
const params = useParams()
@@ -41,11 +53,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const openContext = () => {
if (!params.id) return
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.open()
layout.fileTree.setTab("all")
tabs().open("context")
tabs().setActive("context")
openSessionContext({
view: view(),
layout,
tabs: tabs(),
})
}
const circle = () => (
@@ -0,0 +1,61 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { estimateSessionContextBreakdown } from "./session-context-breakdown"
const user = (id: string) => {
return {
id,
role: "user",
time: { created: 1 },
} as unknown as Message
}
const assistant = (id: string) => {
return {
id,
role: "assistant",
time: { created: 1 },
} as unknown as Message
}
describe("estimateSessionContextBreakdown", () => {
test("estimates tokens and keeps remaining tokens as other", () => {
const messages = [user("u1"), assistant("a1")]
const parts = {
u1: [{ type: "text", text: "hello world" }] as unknown as Part[],
a1: [{ type: "text", text: "assistant response" }] as unknown as Part[],
}
const output = estimateSessionContextBreakdown({
messages,
parts,
input: 20,
systemPrompt: "system prompt",
})
const map = Object.fromEntries(output.map((segment) => [segment.key, segment.tokens]))
expect(map.system).toBe(4)
expect(map.user).toBe(3)
expect(map.assistant).toBe(5)
expect(map.other).toBe(8)
})
test("scales segments when estimates exceed input", () => {
const messages = [user("u1"), assistant("a1")]
const parts = {
u1: [{ type: "text", text: "x".repeat(400) }] as unknown as Part[],
a1: [{ type: "text", text: "y".repeat(400) }] as unknown as Part[],
}
const output = estimateSessionContextBreakdown({
messages,
parts,
input: 10,
systemPrompt: "z".repeat(200),
})
const total = output.reduce((sum, segment) => sum + segment.tokens, 0)
expect(total).toBeLessThanOrEqual(10)
expect(output.every((segment) => segment.width <= 100)).toBeTrue()
})
})
@@ -0,0 +1,132 @@
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other"
export type SessionContextBreakdownSegment = {
key: SessionContextBreakdownKey
tokens: number
width: number
percent: number
}
const estimateTokens = (chars: number) => Math.ceil(chars / 4)
const toPercent = (tokens: number, input: number) => (tokens / input) * 100
const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10
const charsFromUserPart = (part: Part) => {
if (part.type === "text") return part.text.length
if (part.type === "file") return part.source?.text.value.length ?? 0
if (part.type === "agent") return part.source?.value.length ?? 0
return 0
}
const charsFromAssistantPart = (part: Part) => {
if (part.type === "text") return { assistant: part.text.length, tool: 0 }
if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 }
if (part.type !== "tool") return { assistant: 0, tool: 0 }
const input = Object.keys(part.state.input).length * 16
if (part.state.status === "pending") return { assistant: 0, tool: input + part.state.raw.length }
if (part.state.status === "completed") return { assistant: 0, tool: input + part.state.output.length }
if (part.state.status === "error") return { assistant: 0, tool: input + part.state.error.length }
return { assistant: 0, tool: input }
}
const build = (
tokens: { system: number; user: number; assistant: number; tool: number; other: number },
input: number,
) => {
return [
{
key: "system",
tokens: tokens.system,
},
{
key: "user",
tokens: tokens.user,
},
{
key: "assistant",
tokens: tokens.assistant,
},
{
key: "tool",
tokens: tokens.tool,
},
{
key: "other",
tokens: tokens.other,
},
]
.filter((x) => x.tokens > 0)
.map((x) => ({
key: x.key,
tokens: x.tokens,
width: toPercent(x.tokens, input),
percent: toPercentLabel(x.tokens, input),
})) as SessionContextBreakdownSegment[]
}
export function estimateSessionContextBreakdown(args: {
messages: Message[]
parts: Record<string, Part[] | undefined>
input: number
systemPrompt?: string
}) {
if (!args.input) return []
const counts = args.messages.reduce(
(acc, msg) => {
const parts = args.parts[msg.id] ?? []
if (msg.role === "user") {
const user = parts.reduce((sum, part) => sum + charsFromUserPart(part), 0)
return { ...acc, user: acc.user + user }
}
if (msg.role !== "assistant") return acc
const assistant = parts.reduce(
(sum, part) => {
const next = charsFromAssistantPart(part)
return {
assistant: sum.assistant + next.assistant,
tool: sum.tool + next.tool,
}
},
{ assistant: 0, tool: 0 },
)
return {
...acc,
assistant: acc.assistant + assistant.assistant,
tool: acc.tool + assistant.tool,
}
},
{
system: args.systemPrompt?.length ?? 0,
user: 0,
assistant: 0,
tool: 0,
},
)
const tokens = {
system: estimateTokens(counts.system),
user: estimateTokens(counts.user),
assistant: estimateTokens(counts.assistant),
tool: estimateTokens(counts.tool),
}
const estimated = tokens.system + tokens.user + tokens.assistant + tokens.tool
if (estimated <= args.input) {
return build({ ...tokens, other: args.input - estimated }, args.input)
}
const scale = args.input / estimated
const scaled = {
system: Math.floor(tokens.system * scale),
user: Math.floor(tokens.user * scale),
assistant: Math.floor(tokens.assistant * scale),
tool: Math.floor(tokens.tool * scale),
}
const total = scaled.system + scaled.user + scaled.assistant + scaled.tool
return build({ ...scaled, other: Math.max(0, args.input - total) }, args.input)
}
@@ -0,0 +1,20 @@
import { DateTime } from "luxon"
export function createSessionContextFormatter(locale: string) {
return {
number(value: number | null | undefined) {
if (value === undefined) return "—"
if (value === null) return "—"
return value.toLocaleString(locale)
},
percent(value: number | null | undefined) {
if (value === undefined) return "—"
if (value === null) return "—"
return value.toLocaleString(locale) + "%"
},
time(value: number | undefined) {
if (!value) return "—"
return DateTime.fromMillis(value).setLocale(locale).toLocaleString(DateTime.DATETIME_MED)
},
}
}
@@ -91,4 +91,11 @@ describe("getSessionContextMetrics", () => {
expect(two.context?.message.id).toBe("a2")
expect(two.totalCost).toBe(1)
})
test("returns empty metrics when inputs are undefined", () => {
const metrics = getSessionContextMetrics(undefined, undefined)
expect(metrics.totalCost).toBe(0)
expect(metrics.context).toBeUndefined()
})
})
@@ -47,7 +47,7 @@ const lastAssistantWithTokens = (messages: Message[]) => {
}
}
const build = (messages: Message[], providers: Provider[]): Metrics => {
const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => {
const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
const message = lastAssistantWithTokens(messages)
if (!message) return { totalCost, context: undefined }
@@ -77,6 +77,6 @@ const build = (messages: Message[], providers: Provider[]): Metrics => {
}
}
export function getSessionContextMetrics(messages: Message[], providers: Provider[]) {
export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = []) {
return build(messages, providers)
}
@@ -1,7 +1,6 @@
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
import type { JSX } from "solid-js"
import { useParams } from "@solidjs/router"
import { DateTime } from "luxon"
import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout"
import { checksum } from "@opencode-ai/util/encode"
@@ -14,6 +13,8 @@ import { Markdown } from "@opencode-ai/ui/markdown"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language"
import { getSessionContextMetrics } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format"
interface SessionContextTabProps {
messages: () => Message[]
@@ -22,6 +23,74 @@ interface SessionContextTabProps {
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
}
const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
system: "var(--syntax-info)",
user: "var(--syntax-success)",
assistant: "var(--syntax-property)",
tool: "var(--syntax-warning)",
other: "var(--syntax-comment)",
}
function Stat(props: { label: string; value: JSX.Element }) {
return (
<div class="flex flex-col gap-1">
<div class="text-12-regular text-text-weak">{props.label}</div>
<div class="text-12-medium text-text-strong">{props.value}</div>
</div>
)
}
function RawMessageContent(props: { message: Message; getParts: (id: string) => Part[]; onRendered: () => void }) {
const file = createMemo(() => {
const parts = props.getParts(props.message.id)
const contents = JSON.stringify({ message: props.message, parts }, null, 2)
return {
name: `${props.message.role}-${props.message.id}.json`,
contents,
cacheKey: checksum(contents),
}
})
return (
<Code
file={file()}
overflow="wrap"
class="select-text"
onRendered={() => requestAnimationFrame(props.onRendered)}
/>
)
}
function RawMessage(props: {
message: Message
getParts: (id: string) => Part[]
onRendered: () => void
time: (value: number | undefined) => string
}) {
return (
<Accordion.Item value={props.message.id}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div class="flex items-center justify-between gap-2 w-full">
<div class="min-w-0 truncate">
{props.message.role} <span class="text-text-base"> {props.message.id}</span>
</div>
<div class="flex items-center gap-3">
<div class="shrink-0 text-12-regular text-text-weak">{props.time(props.message.time.created)}</div>
<Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" />
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content class="bg-background-base">
<div class="p-3">
<RawMessageContent message={props.message} getParts={props.getParts} onRendered={props.onRendered} />
</div>
</Accordion.Content>
</Accordion.Item>
)
}
export function SessionContextTab(props: SessionContextTabProps) {
const params = useParams()
const sync = useSync()
@@ -37,6 +106,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
const cost = createMemo(() => {
return usd().format(metrics().totalCost)
@@ -62,23 +132,6 @@ export function SessionContextTab(props: SessionContextTabProps) {
return trimmed
})
const number = (value: number | null | undefined) => {
if (value === undefined) return "—"
if (value === null) return "—"
return value.toLocaleString(language.locale())
}
const percent = (value: number | null | undefined) => {
if (value === undefined) return "—"
if (value === null) return "—"
return value.toLocaleString(language.locale()) + "%"
}
const time = (value: number | undefined) => {
if (!value) return "—"
return DateTime.fromMillis(value).setLocale(language.locale()).toLocaleString(DateTime.DATETIME_MED)
}
const providerLabel = createMemo(() => {
const c = ctx()
if (!c) return "—"
@@ -96,197 +149,51 @@ export function SessionContextTab(props: SessionContextTabProps) {
() => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
() => {
const c = ctx()
if (!c) return []
const input = c.input
if (!input) return []
const out = {
system: systemPrompt()?.length ?? 0,
user: 0,
assistant: 0,
tool: 0,
}
for (const msg of props.messages()) {
const parts = (sync.data.part[msg.id] ?? []) as Part[]
if (msg.role === "user") {
for (const part of parts) {
if (part.type === "text") out.user += part.text.length
if (part.type === "file") out.user += part.source?.text.value.length ?? 0
if (part.type === "agent") out.user += part.source?.value.length ?? 0
}
continue
}
if (msg.role === "assistant") {
for (const part of parts) {
if (part.type === "text") out.assistant += part.text.length
if (part.type === "reasoning") out.assistant += part.text.length
if (part.type === "tool") {
out.tool += Object.keys(part.state.input).length * 16
if (part.state.status === "pending") out.tool += part.state.raw.length
if (part.state.status === "completed") out.tool += part.state.output.length
if (part.state.status === "error") out.tool += part.state.error.length
}
}
}
}
const estimateTokens = (chars: number) => Math.ceil(chars / 4)
const system = estimateTokens(out.system)
const user = estimateTokens(out.user)
const assistant = estimateTokens(out.assistant)
const tool = estimateTokens(out.tool)
const estimated = system + user + assistant + tool
const pct = (tokens: number) => (tokens / input) * 100
const pctLabel = (tokens: number) => (Math.round(pct(tokens) * 10) / 10).toString() + "%"
const build = (tokens: { system: number; user: number; assistant: number; tool: number; other: number }) => {
return [
{
key: "system",
label: language.t("context.breakdown.system"),
tokens: tokens.system,
width: pct(tokens.system),
percent: pctLabel(tokens.system),
color: "var(--syntax-info)",
},
{
key: "user",
label: language.t("context.breakdown.user"),
tokens: tokens.user,
width: pct(tokens.user),
percent: pctLabel(tokens.user),
color: "var(--syntax-success)",
},
{
key: "assistant",
label: language.t("context.breakdown.assistant"),
tokens: tokens.assistant,
width: pct(tokens.assistant),
percent: pctLabel(tokens.assistant),
color: "var(--syntax-property)",
},
{
key: "tool",
label: language.t("context.breakdown.tool"),
tokens: tokens.tool,
width: pct(tokens.tool),
percent: pctLabel(tokens.tool),
color: "var(--syntax-warning)",
},
{
key: "other",
label: language.t("context.breakdown.other"),
tokens: tokens.other,
width: pct(tokens.other),
percent: pctLabel(tokens.other),
color: "var(--syntax-comment)",
},
].filter((x) => x.tokens > 0)
}
if (estimated <= input) {
return build({ system, user, assistant, tool, other: input - estimated })
}
const scale = input / estimated
const scaled = {
system: Math.floor(system * scale),
user: Math.floor(user * scale),
assistant: Math.floor(assistant * scale),
tool: Math.floor(tool * scale),
}
const scaledTotal = scaled.system + scaled.user + scaled.assistant + scaled.tool
return build({ ...scaled, other: Math.max(0, input - scaledTotal) })
if (!c?.input) return []
return estimateSessionContextBreakdown({
messages: props.messages(),
parts: sync.data.part as Record<string, Part[] | undefined>,
input: c.input,
systemPrompt: systemPrompt(),
})
},
),
)
function Stat(statProps: { label: string; value: JSX.Element }) {
return (
<div class="flex flex-col gap-1">
<div class="text-12-regular text-text-weak">{statProps.label}</div>
<div class="text-12-medium text-text-strong">{statProps.value}</div>
</div>
)
const breakdownLabel = (key: SessionContextBreakdownKey) => {
if (key === "system") return language.t("context.breakdown.system")
if (key === "user") return language.t("context.breakdown.user")
if (key === "assistant") return language.t("context.breakdown.assistant")
if (key === "tool") return language.t("context.breakdown.tool")
return language.t("context.breakdown.other")
}
const stats = createMemo(() => {
const c = ctx()
const count = counts()
return [
{ label: language.t("context.stats.session"), value: props.info()?.title ?? params.id ?? "—" },
{ label: language.t("context.stats.messages"), value: count.all.toLocaleString(language.locale()) },
{ label: language.t("context.stats.provider"), value: providerLabel() },
{ label: language.t("context.stats.model"), value: modelLabel() },
{ label: language.t("context.stats.limit"), value: number(c?.limit) },
{ label: language.t("context.stats.totalTokens"), value: number(c?.total) },
{ label: language.t("context.stats.usage"), value: percent(c?.usage) },
{ label: language.t("context.stats.inputTokens"), value: number(c?.input) },
{ label: language.t("context.stats.outputTokens"), value: number(c?.output) },
{ label: language.t("context.stats.reasoningTokens"), value: number(c?.reasoning) },
{
label: language.t("context.stats.cacheTokens"),
value: `${number(c?.cacheRead)} / ${number(c?.cacheWrite)}`,
},
{ label: language.t("context.stats.userMessages"), value: count.user.toLocaleString(language.locale()) },
{
label: language.t("context.stats.assistantMessages"),
value: count.assistant.toLocaleString(language.locale()),
},
{ label: language.t("context.stats.totalCost"), value: cost() },
{ label: language.t("context.stats.sessionCreated"), value: time(props.info()?.time.created) },
{ label: language.t("context.stats.lastActivity"), value: time(c?.message.time.created) },
] satisfies { label: string; value: JSX.Element }[]
})
function RawMessageContent(msgProps: { message: Message }) {
const file = createMemo(() => {
const parts = (sync.data.part[msgProps.message.id] ?? []) as Part[]
const contents = JSON.stringify({ message: msgProps.message, parts }, null, 2)
return {
name: `${msgProps.message.role}-${msgProps.message.id}.json`,
contents,
cacheKey: checksum(contents),
}
})
return (
<Code file={file()} overflow="wrap" class="select-text" onRendered={() => requestAnimationFrame(restoreScroll)} />
)
}
function RawMessage(msgProps: { message: Message }) {
return (
<Accordion.Item value={msgProps.message.id}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div class="flex items-center justify-between gap-2 w-full">
<div class="min-w-0 truncate">
{msgProps.message.role} <span class="text-text-base"> {msgProps.message.id}</span>
</div>
<div class="flex items-center gap-3">
<div class="shrink-0 text-12-regular text-text-weak">{time(msgProps.message.time.created)}</div>
<Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" />
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content class="bg-background-base">
<div class="p-3">
<RawMessageContent message={msgProps.message} />
</div>
</Accordion.Content>
</Accordion.Item>
)
}
const stats = [
{ label: "context.stats.session", value: () => props.info()?.title ?? params.id ?? "—" },
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) },
{ label: "context.stats.provider", value: providerLabel },
{ label: "context.stats.model", value: modelLabel },
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) },
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.reasoning) },
{
label: "context.stats.cacheTokens",
value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`,
},
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) },
{ label: "context.stats.totalCost", value: cost },
{ label: "context.stats.sessionCreated", value: () => formatter().time(props.info()?.time.created) },
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
] satisfies { label: string; value: () => JSX.Element }[]
let scroll: HTMLDivElement | undefined
let frame: number | undefined
let pending: { x: number; y: number } | undefined
const getParts = (id: string) => (sync.data.part[id] ?? []) as Part[]
const restoreScroll = () => {
const el = scroll
@@ -343,7 +250,9 @@ export function SessionContextTab(props: SessionContextTabProps) {
>
<div class="px-6 pt-4 flex flex-col gap-10">
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
<For each={stats()}>{(stat) => <Stat label={stat.label} value={stat.value} />}</For>
<For each={stats}>
{(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}
</For>
</div>
<Show when={breakdown().length > 0}>
@@ -356,7 +265,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
class="h-full"
style={{
width: `${segment.width}%`,
"background-color": segment.color,
"background-color": BREAKDOWN_COLOR[segment.key],
}}
/>
)}
@@ -366,9 +275,9 @@ export function SessionContextTab(props: SessionContextTabProps) {
<For each={breakdown()}>
{(segment) => (
<div class="flex items-center gap-1 text-11-regular text-text-weak">
<div class="size-2 rounded-sm" style={{ "background-color": segment.color }} />
<div>{segment.label}</div>
<div class="text-text-weaker">{segment.percent}</div>
<div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} />
<div>{breakdownLabel(segment.key)}</div>
<div class="text-text-weaker">{segment.percent.toLocaleString(language.locale())}%</div>
</div>
)}
</For>
@@ -391,7 +300,11 @@ export function SessionContextTab(props: SessionContextTabProps) {
<div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
<Accordion multiple>
<For each={props.messages()}>{(message) => <RawMessage message={message} />}</For>
<For each={props.messages()}>
{(message) => (
<RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
)}
</For>
</Accordion>
</div>
</div>
@@ -1,4 +1,4 @@
import { createEffect, createMemo, onCleanup, Show } from "solid-js"
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useParams } from "@solidjs/router"
@@ -25,6 +25,164 @@ import { Keybind } from "@opencode-ai/ui/keybind"
import { showToast } from "@opencode-ai/ui/toast"
import { StatusPopover } from "../status-popover"
const OPEN_APPS = [
"vscode",
"cursor",
"zed",
"textmate",
"antigravity",
"finder",
"terminal",
"iterm2",
"ghostty",
"xcode",
"android-studio",
"powershell",
"sublime-text",
] as const
type OpenApp = (typeof OPEN_APPS)[number]
type OS = "macos" | "windows" | "linux" | "unknown"
const MAC_APPS = [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
{ id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
{ id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" },
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
{ id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" },
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
] as const
const WINDOWS_APPS = [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
{ id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" },
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
] as const
const LINUX_APPS = [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
] as const
type OpenOption = (typeof MAC_APPS)[number] | (typeof WINDOWS_APPS)[number] | (typeof LINUX_APPS)[number]
type OpenIcon = OpenApp | "file-explorer"
const OPEN_ICON_BASE = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"])
const openIconSize = (id: OpenIcon) => (OPEN_ICON_BASE.has(id) ? "size-4" : "size-[19px]")
const detectOS = (platform: ReturnType<typeof usePlatform>): OS => {
if (platform.platform === "desktop" && platform.os) return platform.os
if (typeof navigator !== "object") return "unknown"
const value = navigator.platform || navigator.userAgent
if (/Mac/i.test(value)) return "macos"
if (/Win/i.test(value)) return "windows"
if (/Linux/i.test(value)) return "linux"
return "unknown"
}
const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
function useSessionShare(args: {
globalSDK: ReturnType<typeof useGlobalSDK>
currentSession: () =>
| {
id: string
share?: {
url?: string
}
}
| undefined
projectDirectory: () => string
platform: ReturnType<typeof usePlatform>
}) {
const [state, setState] = createStore({
share: false,
unshare: false,
copied: false,
timer: undefined as number | undefined,
})
const shareUrl = createMemo(() => args.currentSession()?.share?.url)
createEffect(() => {
const url = shareUrl()
if (url) return
if (state.timer) window.clearTimeout(state.timer)
setState({ copied: false, timer: undefined })
})
onCleanup(() => {
if (state.timer) window.clearTimeout(state.timer)
})
const shareSession = () => {
const session = args.currentSession()
if (!session || state.share) return
setState("share", true)
args.globalSDK.client.session
.share({ sessionID: session.id, directory: args.projectDirectory() })
.catch((error) => {
console.error("Failed to share session", error)
})
.finally(() => {
setState("share", false)
})
}
const unshareSession = () => {
const session = args.currentSession()
if (!session || state.unshare) return
setState("unshare", true)
args.globalSDK.client.session
.unshare({ sessionID: session.id, directory: args.projectDirectory() })
.catch((error) => {
console.error("Failed to unshare session", error)
})
.finally(() => {
setState("unshare", false)
})
}
const copyLink = (onError: (error: unknown) => void) => {
const url = shareUrl()
if (!url) return
navigator.clipboard
.writeText(url)
.then(() => {
if (state.timer) window.clearTimeout(state.timer)
setState("copied", true)
const timer = window.setTimeout(() => {
setState("copied", false)
setState("timer", undefined)
}, 3000)
setState("timer", timer)
})
.catch(onError)
}
const viewShare = () => {
const url = shareUrl()
if (!url) return
args.platform.openLink(url)
}
return { state, shareUrl, shareSession, unshareSession, copyLink, viewShare }
}
export function SessionHeader() {
const globalSDK = useGlobalSDK()
const layout = useLayout()
@@ -53,62 +211,7 @@ export function SessionHeader() {
const showShare = createMemo(() => shareEnabled() && !!currentSession())
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey))
const OPEN_APPS = [
"vscode",
"cursor",
"zed",
"textmate",
"antigravity",
"finder",
"terminal",
"iterm2",
"ghostty",
"xcode",
"android-studio",
"powershell",
"sublime-text",
] as const
type OpenApp = (typeof OPEN_APPS)[number]
const MAC_APPS = [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
{ id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
{ id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" },
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
{ id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" },
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
] as const
const WINDOWS_APPS = [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
{ id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" },
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
] as const
const LINUX_APPS = [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
] as const
const os = createMemo<"macos" | "windows" | "linux" | "unknown">(() => {
if (platform.platform === "desktop" && platform.os) return platform.os
if (typeof navigator !== "object") return "unknown"
const value = navigator.platform || navigator.userAgent
if (/Mac/i.test(value)) return "macos"
if (/Win/i.test(value)) return "windows"
if (/Linux/i.test(value)) return "linux"
return "unknown"
})
const os = createMemo(() => detectOS(platform))
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ finder: true })
@@ -154,10 +257,6 @@ export function SessionHeader() {
] as const
})
type OpenIcon = OpenApp | "file-explorer"
const base = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"])
const size = (id: OpenIcon) => (base.has(id) ? "size-4" : "size-[19px]")
const checksReady = createMemo(() => {
if (platform.platform !== "desktop") return true
if (!platform.checkAppExists) return true
@@ -186,13 +285,7 @@ export function SessionHeader() {
const item = options().find((o) => o.id === app)
const openWith = item && "openWith" in item ? item.openWith : undefined
Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
})
Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => showRequestError(language, err))
}
const copyPath = () => {
@@ -208,86 +301,15 @@ export function SessionHeader() {
description: directory,
})
})
.catch((err: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
})
.catch((err: unknown) => showRequestError(language, err))
}
const [state, setState] = createStore({
share: false,
unshare: false,
copied: false,
timer: undefined as number | undefined,
const share = useSessionShare({
globalSDK,
currentSession,
projectDirectory,
platform,
})
const shareUrl = createMemo(() => currentSession()?.share?.url)
createEffect(() => {
const url = shareUrl()
if (url) return
if (state.timer) window.clearTimeout(state.timer)
setState({ copied: false, timer: undefined })
})
onCleanup(() => {
if (state.timer) window.clearTimeout(state.timer)
})
function shareSession() {
const session = currentSession()
if (!session || state.share) return
setState("share", true)
globalSDK.client.session
.share({ sessionID: session.id, directory: projectDirectory() })
.catch((error) => {
console.error("Failed to share session", error)
})
.finally(() => {
setState("share", false)
})
}
function unshareSession() {
const session = currentSession()
if (!session || state.unshare) return
setState("unshare", true)
globalSDK.client.session
.unshare({ sessionID: session.id, directory: projectDirectory() })
.catch((error) => {
console.error("Failed to unshare session", error)
})
.finally(() => {
setState("unshare", false)
})
}
function copyLink() {
const url = shareUrl()
if (!url) return
navigator.clipboard
.writeText(url)
.then(() => {
if (state.timer) window.clearTimeout(state.timer)
setState("copied", true)
const timer = window.setTimeout(() => {
setState("copied", false)
setState("timer", undefined)
}, 3000)
setState("timer", timer)
})
.catch((error) => {
console.error("Failed to copy share link", error)
})
}
function viewShare() {
const url = shareUrl()
if (!url) return
platform.openLink(url)
}
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
@@ -329,17 +351,19 @@ export function SessionHeader() {
<Show
when={canOpen()}
fallback={
<Button
variant="ghost"
class="rounded-sm h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none"
onClick={copyPath}
aria-label={language.t("session.header.open.copyPath")}
>
<Icon name="copy" size="small" class="text-icon-base" />
<span class="text-12-regular text-text-strong">
{language.t("session.header.open.copyPath")}
</span>
</Button>
<div class="flex h-[24px] box-border items-center rounded-md border border-border-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-2 gap-2 border-none shadow-none"
onClick={copyPath}
aria-label={language.t("session.header.open.copyPath")}
>
<Icon name="copy" size="small" class="text-icon-base" />
<span class="text-12-regular text-text-strong">
{language.t("session.header.open.copyPath")}
</span>
</Button>
</div>
}
>
<div class="flex items-center">
@@ -380,23 +404,25 @@ export function SessionHeader() {
setPrefs("app", value as OpenApp)
}}
>
{options().map((o) => (
<DropdownMenu.RadioItem
value={o.id}
onSelect={() => {
setMenu("open", false)
openDir(o.id)
}}
>
<div class="flex size-5 shrink-0 items-center justify-center">
<AppIcon id={o.icon} class={size(o.icon)} />
</div>
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator>
<Icon name="check-small" size="small" class="text-icon-weak" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
))}
<For each={options()}>
{(o) => (
<DropdownMenu.RadioItem
value={o.id}
onSelect={() => {
setMenu("open", false)
openDir(o.id)
}}
>
<div class="flex size-5 shrink-0 items-center justify-center">
<AppIcon id={o.icon} class={openIconSize(o.icon)} />
</div>
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator>
<Icon name="check-small" size="small" class="text-icon-weak" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
)}
</For>
</DropdownMenu.RadioGroup>
</DropdownMenu.Group>
<DropdownMenu.Separator />
@@ -426,7 +452,7 @@ export function SessionHeader() {
<Popover
title={language.t("session.share.popover.title")}
description={
shareUrl()
share.shareUrl()
? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")
}
@@ -439,24 +465,24 @@ export function SessionHeader() {
variant: "ghost",
class:
"rounded-md h-[24px] px-3 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
classList: { "rounded-r-none": shareUrl() !== undefined },
classList: { "rounded-r-none": share.shareUrl() !== undefined },
style: { scale: 1 },
}}
trigger={language.t("session.share.action.share")}
>
<div class="flex flex-col gap-2">
<Show
when={shareUrl()}
when={share.shareUrl()}
fallback={
<div class="flex">
<Button
size="large"
variant="primary"
class="w-1/2"
onClick={shareSession}
disabled={state.share}
onClick={share.shareSession}
disabled={share.state.share}
>
{state.share
{share.state.share
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button>
@@ -465,7 +491,7 @@ export function SessionHeader() {
>
<div class="flex flex-col gap-2">
<TextField
value={shareUrl() ?? ""}
value={share.shareUrl() ?? ""}
readOnly
copyable
copyKind="link"
@@ -477,10 +503,10 @@ export function SessionHeader() {
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession}
disabled={state.unshare}
onClick={share.unshareSession}
disabled={share.state.unshare}
>
{state.unshare
{share.state.unshare
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
@@ -488,8 +514,8 @@ export function SessionHeader() {
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={state.unshare}
onClick={share.viewShare}
disabled={share.state.unshare}
>
{language.t("session.share.action.view")}
</Button>
@@ -498,10 +524,10 @@ export function SessionHeader() {
</Show>
</div>
</Popover>
<Show when={shareUrl()} fallback={<div aria-hidden="true" />}>
<Show when={share.shareUrl()} fallback={<div aria-hidden="true" />}>
<Tooltip
value={
state.copied
share.state.copied
? language.t("session.share.copy.copied")
: language.t("session.share.copy.copyLink")
}
@@ -509,13 +535,13 @@ export function SessionHeader() {
gutter={8}
>
<IconButton
icon={state.copied ? "check" : "link"}
icon={share.state.copied ? "check" : "link"}
variant="ghost"
class="rounded-l-none h-[24px] border border-border-base bg-surface-panel shadow-none"
onClick={copyLink}
disabled={state.unshare}
onClick={() => share.copyLink((error) => showRequestError(language, error))}
disabled={share.state.unshare}
aria-label={
state.copied
share.state.copied
? language.t("session.share.copy.copied")
: language.t("session.share.copy.copyLink")
}
@@ -8,6 +8,8 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
const MAIN_WORKTREE = "main"
const CREATE_WORKTREE = "create"
const ROOT_CLASS =
"size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]"
interface NewSessionViewProps {
worktree: string
@@ -47,7 +49,7 @@ export function NewSessionView(props: NewSessionViewProps) {
}
return (
<div class="size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]">
<div class={ROOT_CLASS}>
<div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div>
<div class="flex justify-center items-center gap-3">
<Icon name="folder" size="small" />
@@ -31,8 +31,12 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
const command = useCommand()
const sortable = createSortable(props.tab)
const path = createMemo(() => file.pathFromTab(props.tab))
const content = createMemo(() => {
const value = path()
if (!value) return
return <FileVisual path={value} />
})
return (
// @ts-ignore
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
<div class="relative h-full">
<Tabs.Trigger
@@ -55,7 +59,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}
>
<Show when={path()}>{(p) => <FileVisual path={p()} />}</Show>
<Show when={content()}>{(value) => value()}</Show>
</Tabs.Trigger>
</div>
</div>
@@ -1,5 +1,5 @@
import type { JSX } from "solid-js"
import { Show } from "solid-js"
import { Show, createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createSortable } from "@thisbeyond/solid-dnd"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -20,6 +20,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
menuPosition: { x: 0, y: 0 },
blurEnabled: false,
})
let input: HTMLInputElement | undefined
let blurFrame: number | undefined
const isDefaultTitle = () => {
const number = props.terminal.titleNumber
@@ -77,13 +79,6 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
setStore("blurEnabled", false)
setStore("title", props.terminal.title)
setStore("editing", true)
setTimeout(() => {
const input = document.getElementById(`terminal-title-input-${props.terminal.id}`) as HTMLInputElement
if (!input) return
input.focus()
input.select()
setTimeout(() => setStore("blurEnabled", true), 100)
}, 10)
}
const save = () => {
@@ -114,9 +109,25 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
setStore("menuOpen", true)
}
createEffect(() => {
if (!store.editing) return
if (!input) return
input.focus()
input.select()
if (blurFrame !== undefined) cancelAnimationFrame(blurFrame)
blurFrame = requestAnimationFrame(() => {
blurFrame = undefined
setStore("blurEnabled", true)
})
})
onCleanup(() => {
if (blurFrame === undefined) return
cancelAnimationFrame(blurFrame)
})
return (
<div
// @ts-ignore
use:sortable
class="outline-none focus:outline-none focus-visible:outline-none"
classList={{
@@ -153,7 +164,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
<Show when={store.editing}>
<div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto">
<input
id={`terminal-title-input-${props.terminal.id}`}
ref={input}
type="text"
value={store.title}
onInput={(e) => setStore("title", e.currentTarget.value)}
@@ -2,6 +2,7 @@ import { Component } from "solid-js"
import { useLanguage } from "@/context/language"
export const SettingsAgents: Component = () => {
// TODO: Replace this placeholder with full agents settings controls.
const language = useLanguage()
return (
@@ -2,6 +2,7 @@ import { Component } from "solid-js"
import { useLanguage } from "@/context/language"
export const SettingsCommands: Component = () => {
// TODO: Replace this placeholder with full commands settings controls.
const language = useLanguage()
return (
+284 -266
View File
@@ -1,4 +1,4 @@
import { Component, Show, createEffect, createMemo, createResource, type JSX } from "solid-js"
import { Component, Show, createMemo, createResource, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
@@ -133,6 +133,261 @@ export const SettingsGeneral: Component = () => {
const soundOptions = [...SOUND_OPTIONS]
const soundSelectProps = (current: () => string, set: (id: string) => void) => ({
options: soundOptions,
current: soundOptions.find((o) => o.id === current()),
value: (o: (typeof soundOptions)[number]) => o.id,
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
playDemoSound(option.src)
},
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
set(option.id)
playDemoSound(option.src)
},
variant: "secondary" as const,
size: "small" as const,
triggerVariant: "settings" as const,
})
const AppearanceSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.general.row.language.title")}
description={language.t("settings.general.row.language.description")}
>
<Select
data-action="settings-language"
options={languageOptions()}
current={languageOptions().find((o) => o.value === language.locale())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && language.setLocale(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.appearance.title")}
description={language.t("settings.general.row.appearance.description")}
>
<Select
data-action="settings-color-scheme"
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={language.t("settings.general.row.theme.title")}
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
</>
}
>
<Select
data-action="settings-theme"
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={language.t("settings.general.row.font.title")}
description={language.t("settings.general.row.font.description")}
>
<Select
data-action="settings-font"
options={fontOptionsList}
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
value={(o) => o.value}
label={(o) => language.t(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 ? language.t(option.label) : ""}
</span>
)}
</Select>
</SettingsRow>
</div>
</div>
)
const NotificationsSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.general.notifications.agent.title")}
description={language.t("settings.general.notifications.agent.description")}
>
<div data-action="settings-notifications-agent">
<Switch
checked={settings.notifications.agent()}
onChange={(checked) => settings.notifications.setAgent(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.notifications.permissions.title")}
description={language.t("settings.general.notifications.permissions.description")}
>
<div data-action="settings-notifications-permissions">
<Switch
checked={settings.notifications.permissions()}
onChange={(checked) => settings.notifications.setPermissions(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.notifications.errors.title")}
description={language.t("settings.general.notifications.errors.description")}
>
<div data-action="settings-notifications-errors">
<Switch
checked={settings.notifications.errors()}
onChange={(checked) => settings.notifications.setErrors(checked)}
/>
</div>
</SettingsRow>
</div>
</div>
)
const SoundsSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.general.sounds.agent.title")}
description={language.t("settings.general.sounds.agent.description")}
>
<Select
data-action="settings-sounds-agent"
{...soundSelectProps(
() => settings.sounds.agent(),
(id) => settings.sounds.setAgent(id),
)}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.permissions.title")}
description={language.t("settings.general.sounds.permissions.description")}
>
<Select
data-action="settings-sounds-permissions"
{...soundSelectProps(
() => settings.sounds.permissions(),
(id) => settings.sounds.setPermissions(id),
)}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.errors.title")}
description={language.t("settings.general.sounds.errors.description")}
>
<Select
data-action="settings-sounds-errors"
{...soundSelectProps(
() => settings.sounds.errors(),
(id) => settings.sounds.setErrors(id),
)}
/>
</SettingsRow>
</div>
</div>
)
const UpdatesSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.updates.row.startup.title")}
description={language.t("settings.updates.row.startup.description")}
>
<div data-action="settings-updates-startup">
<Switch
checked={settings.updates.startup()}
disabled={!platform.checkUpdate}
onChange={(checked) => settings.updates.setStartup(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.releaseNotes.title")}
description={language.t("settings.general.row.releaseNotes.description")}
>
<div data-action="settings-release-notes">
<Switch
checked={settings.general.releaseNotes()}
onChange={(checked) => settings.general.setReleaseNotes(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")}
>
<Button size="small" variant="secondary" disabled={store.checking || !platform.checkUpdate} onClick={check}>
{store.checking
? language.t("settings.updates.action.checking")
: language.t("settings.updates.action.checkNow")}
</Button>
</SettingsRow>
</div>
</div>
)
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
@@ -142,278 +397,41 @@ export const SettingsGeneral: Component = () => {
</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">{language.t("settings.general.section.appearance")}</h3>
<AppearanceSection />
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.general.row.language.title")}
description={language.t("settings.general.row.language.description")}
>
<Select
data-action="settings-language"
options={languageOptions()}
current={languageOptions().find((o) => o.value === language.locale())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && language.setLocale(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<NotificationsSection />
<SettingsRow
title={language.t("settings.general.row.appearance.title")}
description={language.t("settings.general.row.appearance.description")}
>
<Select
data-action="settings-color-scheme"
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>
<SoundsSection />
<SettingsRow
title={language.t("settings.general.row.theme.title")}
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
</>
}
>
<Select
data-action="settings-theme"
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>
<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
{(_) => {
const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.())
const enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest)
<SettingsRow
title={language.t("settings.general.row.font.title")}
description={language.t("settings.general.row.font.description")}
>
<Select
data-action="settings-font"
options={fontOptionsList}
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
value={(o) => o.value}
label={(o) => language.t(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 ? language.t(option.label) : ""}
</span>
)}
</Select>
</SettingsRow>
</div>
</div>
return (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.desktop.section.wsl")}</h3>
{/* System notifications Section */}
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.general.notifications.agent.title")}
description={language.t("settings.general.notifications.agent.description")}
>
<div data-action="settings-notifications-agent">
<Switch
checked={settings.notifications.agent()}
onChange={(checked) => settings.notifications.setAgent(checked)}
/>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.desktop.wsl.title")}
description={language.t("settings.desktop.wsl.description")}
>
<div data-action="settings-wsl">
<Switch
checked={enabled() ?? false}
disabled={enabledResource.state === "pending"}
onChange={(checked) => platform.setWslEnabled?.(checked)?.finally(() => actions.refetch())}
/>
</div>
</SettingsRow>
</div>
</div>
</SettingsRow>
)
}}
</Show>
<SettingsRow
title={language.t("settings.general.notifications.permissions.title")}
description={language.t("settings.general.notifications.permissions.description")}
>
<div data-action="settings-notifications-permissions">
<Switch
checked={settings.notifications.permissions()}
onChange={(checked) => settings.notifications.setPermissions(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.notifications.errors.title")}
description={language.t("settings.general.notifications.errors.description")}
>
<div data-action="settings-notifications-errors">
<Switch
checked={settings.notifications.errors()}
onChange={(checked) => settings.notifications.setErrors(checked)}
/>
</div>
</SettingsRow>
</div>
</div>
{/* Sound effects Section */}
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.general.sounds.agent.title")}
description={language.t("settings.general.sounds.agent.description")}
>
<Select
data-action="settings-sounds-agent"
options={soundOptions}
current={soundOptions.find((o) => o.id === settings.sounds.agent())}
value={(o) => o.id}
label={(o) => language.t(o.label)}
onHighlight={(option) => {
if (!option) return
playDemoSound(option.src)
}}
onSelect={(option) => {
if (!option) return
settings.sounds.setAgent(option.id)
playDemoSound(option.src)
}}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.permissions.title")}
description={language.t("settings.general.sounds.permissions.description")}
>
<Select
data-action="settings-sounds-permissions"
options={soundOptions}
current={soundOptions.find((o) => o.id === settings.sounds.permissions())}
value={(o) => o.id}
label={(o) => language.t(o.label)}
onHighlight={(option) => {
if (!option) return
playDemoSound(option.src)
}}
onSelect={(option) => {
if (!option) return
settings.sounds.setPermissions(option.id)
playDemoSound(option.src)
}}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.errors.title")}
description={language.t("settings.general.sounds.errors.description")}
>
<Select
data-action="settings-sounds-errors"
options={soundOptions}
current={soundOptions.find((o) => o.id === settings.sounds.errors())}
value={(o) => o.id}
label={(o) => language.t(o.label)}
onHighlight={(option) => {
if (!option) return
playDemoSound(option.src)
}}
onSelect={(option) => {
if (!option) return
settings.sounds.setErrors(option.id)
playDemoSound(option.src)
}}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
</div>
</div>
{/* Updates Section */}
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.updates.row.startup.title")}
description={language.t("settings.updates.row.startup.description")}
>
<div data-action="settings-updates-startup">
<Switch
checked={settings.updates.startup()}
disabled={!platform.checkUpdate}
onChange={(checked) => settings.updates.setStartup(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.releaseNotes.title")}
description={language.t("settings.general.row.releaseNotes.description")}
>
<div data-action="settings-release-notes">
<Switch
checked={settings.general.releaseNotes()}
onChange={(checked) => settings.general.setReleaseNotes(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")}
>
<Button
size="small"
variant="secondary"
disabled={store.checking || !platform.checkUpdate}
onClick={check}
>
{store.checking
? language.t("settings.updates.action.checking")
: language.t("settings.updates.action.checkNow")}
</Button>
</SettingsRow>
</div>
</div>
<UpdatesSection />
<Show when={linux()}>
{(_) => {
+160 -143
View File
@@ -21,6 +21,9 @@ type KeybindMeta = {
group: KeybindGroup
}
type KeybindMap = Record<string, string | undefined>
type CommandContext = ReturnType<typeof useCommand>
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
type GroupKey =
@@ -107,6 +110,150 @@ function signatures(config: string | undefined) {
return sigs
}
function keybinds(value: unknown): KeybindMap {
if (!value || typeof value !== "object" || Array.isArray(value)) return {}
return value as KeybindMap
}
function listFor(command: CommandContext, map: KeybindMap, palette: string) {
const out = new Map<string, KeybindMeta>()
out.set(PALETTE_ID, { title: 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) })
}
for (const [id, value] of Object.entries(map)) {
if (typeof value !== "string") continue
if (out.has(id)) continue
out.set(id, { title: id, group: groupFor(id) })
}
return out
}
function groupedFor(list: Map<string, KeybindMeta>) {
const out = new Map<KeybindGroup, string[]>()
for (const group of GROUPS) out.set(group, [])
for (const [id, item] of list) {
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) => (list.get(a)?.title ?? "").localeCompare(list.get(b)?.title ?? ""))
}
return out
}
function filteredFor(
query: string,
list: Map<string, KeybindMeta>,
grouped: Map<KeybindGroup, string[]>,
keybind: (id: string) => string,
) {
const value = query.toLowerCase().trim()
if (!value) return grouped
const out = new Map<KeybindGroup, string[]>()
for (const group of GROUPS) out.set(group, [])
const items = Array.from(list.entries()).map(([id, meta]) => ({
id,
title: meta.title,
group: meta.group,
keybind: keybind(id),
}))
const results = fuzzysort.go(value, items, {
keys: ["title", "keybind"],
threshold: -10000,
})
for (const result of results) {
const ids = out.get(result.obj.group)
if (!ids) continue
ids.push(result.obj.id)
}
return out
}
function useKeyCapture(input: {
active: () => string | null
stop: () => void
set: (id: string, keybind: string) => void
used: () => Map<string, { id: string; title: string }[]>
language: ReturnType<typeof useLanguage>
}) {
onMount(() => {
const handle = (event: KeyboardEvent) => {
const id = input.active()
if (!id) return
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
if (event.key === "Escape") {
input.stop()
return
}
const clear =
(event.key === "Backspace" || event.key === "Delete") &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey
if (clear) {
input.set(id, "none")
input.stop()
return
}
const next = recordKeybind(event)
if (!next) return
const conflicts = new Map<string, string>()
for (const sig of signatures(next)) {
for (const item of input.used().get(sig) ?? []) {
if (item.id === id) continue
conflicts.set(item.id, item.title)
}
}
if (conflicts.size > 0) {
showToast({
title: input.language.t("settings.shortcuts.conflict.title"),
description: input.language.t("settings.shortcuts.conflict.description", {
keybind: formatKeybind(next),
titles: [...conflicts.values()].join(", "),
}),
})
return
}
input.set(id, next)
input.stop()
}
document.addEventListener("keydown", handle, true)
onCleanup(() => document.removeEventListener("keydown", handle, true))
})
}
export const SettingsKeybinds: Component = () => {
const command = useCommand()
const language = useLanguage()
@@ -135,11 +282,9 @@ export const SettingsKeybinds: Component = () => {
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 map = createMemo(() => keybinds(settings.current.keybinds))
const hasOverrides = createMemo(() => Object.values(map()).some((x) => typeof x === "string"))
const resetAll = () => {
stop()
@@ -152,88 +297,15 @@ export const SettingsKeybinds: Component = () => {
const list = createMemo(() => {
language.locale()
const out = new Map<string, KeybindMeta>()
out.set(PALETTE_ID, { title: language.t("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
return listFor(command, map(), language.t("command.palette"))
})
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 grouped = createMemo(() => groupedFor(list()))
const filtered = createMemo(() => {
const query = store.filter.toLowerCase().trim()
if (!query) return grouped()
const map = list()
const out = new Map<KeybindGroup, string[]>()
for (const group of GROUPS) out.set(group, [])
const items = Array.from(map.entries()).map(([id, meta]) => ({
id,
title: meta.title,
group: meta.group,
keybind: command.keybind(id) || "",
}))
const results = fuzzysort.go(query, items, {
keys: ["title", "keybind"],
threshold: -10000,
})
for (const result of results) {
const item = result.obj
const ids = out.get(item.group)
if (!ids) continue
ids.push(item.id)
}
return out
return filteredFor(store.filter, list(), grouped(), (id) => command.keybind(id) || "")
})
const hasResults = createMemo(() => {
@@ -282,69 +354,14 @@ export const SettingsKeybinds: Component = () => {
return map
})
const setKeybind = (id: string, keybind: string) => {
settings.keybinds.set(id, keybind)
}
const setKeybind = (id: string, keybind: string) => settings.keybinds.set(id, keybind)
onMount(() => {
const handle = (event: KeyboardEvent) => {
const id = store.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: language.t("settings.shortcuts.conflict.title"),
description: language.t("settings.shortcuts.conflict.description", {
keybind: formatKeybind(next),
titles: [...conflicts.values()].join(", "),
}),
})
return
}
setKeybind(id, next)
stop()
}
document.addEventListener("keydown", handle, true)
onCleanup(() => {
document.removeEventListener("keydown", handle, true)
})
useKeyCapture({
active: () => store.active,
stop,
set: setKeybind,
used,
language,
})
onCleanup(() => {
@@ -2,6 +2,7 @@ import { Component } from "solid-js"
import { useLanguage } from "@/context/language"
export const SettingsMcp: Component = () => {
// TODO: Replace this placeholder with full MCP settings controls.
const language = useLanguage()
return (
+21 -14
View File
@@ -12,6 +12,25 @@ import { popularProviders } from "@/hooks/use-providers"
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
const ListLoadingState: Component<{ label: string }> = (props) => {
return (
<div class="flex flex-col items-center justify-center py-12 text-center">
<span class="text-14-regular text-text-weak">{props.label}</span>
</div>
)
}
const ListEmptyState: Component<{ message: string; filter: string }> = (props) => {
return (
<div class="flex flex-col items-center justify-center py-12 text-center">
<span class="text-14-regular text-text-weak">{props.message}</span>
<Show when={props.filter}>
<span class="text-14-regular text-text-strong mt-1">&quot;{props.filter}&quot;</span>
</Show>
</div>
)
}
export const SettingsModels: Component = () => {
const language = useLanguage()
const models = useModels()
@@ -68,24 +87,12 @@ export const SettingsModels: Component = () => {
<Show
when={!list.grouped.loading}
fallback={
<div class="flex flex-col items-center justify-center py-12 text-center">
<span class="text-14-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</span>
</div>
<ListLoadingState label={`${language.t("common.loading")}${language.t("common.loading.ellipsis")}`} />
}
>
<Show
when={list.flat().length > 0}
fallback={
<div class="flex flex-col items-center justify-center py-12 text-center">
<span class="text-14-regular text-text-weak">{language.t("dialog.model.empty")}</span>
<Show when={list.filter()}>
<span class="text-14-regular text-text-strong mt-1">&quot;{list.filter()}&quot;</span>
</Show>
</div>
}
fallback={<ListEmptyState message={language.t("dialog.model.empty")} filter={list.filter()} />}
>
<For each={list.grouped.latest}>
{(group) => (
@@ -165,12 +165,14 @@ export const SettingsPermissions: Component = () => {
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) => {
const rollback = (err: unknown) => {
globalSync.set("config", "permission", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("settings.permissions.toast.updateFailed.title"), description: message })
})
}
globalSync.set("config", "permission", { ...map, [id]: nextValue })
globalSync.updateConfig({ permission: { [id]: nextValue } }).catch(rollback)
}
return (
@@ -14,7 +14,17 @@ import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogCustomProvider } from "./dialog-custom-provider"
type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderMeta = { source?: ProviderSource }
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
const PROVIDER_NOTES = [
{ match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" },
{ match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" },
{ match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" },
{ match: (id: string) => id === "openai", key: "dialog.provider.openai.note" },
{ match: (id: string) => id === "google", key: "dialog.provider.google.note" },
{ match: (id: string) => id === "openrouter", key: "dialog.provider.openrouter.note" },
{ match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" },
] as const
export const SettingsProviders: Component = () => {
const dialog = useDialog()
@@ -44,22 +54,28 @@ export const SettingsProviders: Component = () => {
return items
})
const source = (item: unknown) => (item as ProviderMeta).source
const source = (item: ProviderItem): ProviderSource | undefined => {
if (!("source" in item)) return
const value = item.source
if (value === "env" || value === "api" || value === "config" || value === "custom") return value
return
}
const type = (item: unknown) => {
const type = (item: ProviderItem) => {
const current = source(item)
if (current === "env") return language.t("settings.providers.tag.environment")
if (current === "api") return language.t("provider.connect.method.apiKey")
if (current === "config") {
const id = (item as { id?: string }).id
if (id && isConfigCustom(id)) return language.t("settings.providers.tag.custom")
if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom")
return language.t("settings.providers.tag.config")
}
if (current === "custom") return language.t("settings.providers.tag.custom")
return language.t("settings.providers.tag.other")
}
const canDisconnect = (item: unknown) => source(item) !== "env"
const canDisconnect = (item: ProviderItem) => source(item) !== "env"
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const isConfigCustom = (providerID: string) => {
const provider = globalSync.data.config.provider?.[providerID]
@@ -175,40 +191,8 @@ export const SettingsProviders: Component = () => {
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
</div>
<Show when={item.id === "opencode"}>
<span class="text-12-regular text-text-weak pl-8">
{language.t("dialog.provider.opencode.note")}
</span>
</Show>
<Show when={item.id === "anthropic"}>
<span class="text-12-regular text-text-weak pl-8">
{language.t("dialog.provider.anthropic.note")}
</span>
</Show>
<Show when={item.id.startsWith("github-copilot")}>
<span class="text-12-regular text-text-weak pl-8">
{language.t("dialog.provider.copilot.note")}
</span>
</Show>
<Show when={item.id === "openai"}>
<span class="text-12-regular text-text-weak pl-8">
{language.t("dialog.provider.openai.note")}
</span>
</Show>
<Show when={item.id === "google"}>
<span class="text-12-regular text-text-weak pl-8">
{language.t("dialog.provider.google.note")}
</span>
</Show>
<Show when={item.id === "openrouter"}>
<span class="text-12-regular text-text-weak pl-8">
{language.t("dialog.provider.openrouter.note")}
</span>
</Show>
<Show when={item.id === "vercel"}>
<span class="text-12-regular text-text-weak pl-8">
{language.t("dialog.provider.vercel.note")}
</span>
<Show when={note(item.id)}>
{(key) => <span class="text-12-regular text-text-weak pl-8">{language.t(key())}</span>}
</Show>
</div>
<Button
+171 -135
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
import { createEffect, createMemo, createSignal, For, onCleanup, Show, type Accessor, type JSXElement } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -7,16 +7,151 @@ import { Tabs } from "@opencode-ai/ui/tabs"
import { Button } from "@opencode-ai/ui/button"
import { Switch } from "@opencode-ai/ui/switch"
import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { DialogSelectServer } from "./dialog-select-server"
import { showToast } from "@opencode-ai/ui/toast"
import { ServerRow } from "@/components/server/server-row"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
const pollMs = 10_000
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
if (parts.length === 1) return value
return (
<>
{parts[0]}
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
{parts.slice(1).join(file)}
</>
)
}
const listServersByHealth = (
list: string[],
active: string | undefined,
status: Record<string, ServerHealth | undefined>,
) => {
if (!list.length) return list
const order = new Map(list.map((url, index) => [url, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff = rank(status[a]) - rank(status[b])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
}
const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) => {
const [status, setStatus] = createStore({} as Record<string, ServerHealth | undefined>)
createEffect(() => {
const list = servers()
let dead = false
const refresh = async () => {
const results: Record<string, ServerHealth> = {}
await Promise.all(
list.map(async (url) => {
results[url] = await checkServerHealth(url, fetcher)
}),
)
if (dead) return
setStatus(reconcile(results))
}
void refresh()
const id = setInterval(() => void refresh(), pollMs)
onCleanup(() => {
dead = true
clearInterval(id)
})
})
return status
}
const useDefaultServerUrl = (
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
) => {
const [url, setUrl] = createSignal<string | undefined>()
const [tick, setTick] = createSignal(0)
createEffect(() => {
tick()
let dead = false
const result = get?.()
if (!result) {
setUrl(undefined)
onCleanup(() => {
dead = true
})
return
}
if (result instanceof Promise) {
void result.then((next) => {
if (dead) return
setUrl(next ? normalizeServerUrl(next) : undefined)
})
onCleanup(() => {
dead = true
})
return
}
setUrl(normalizeServerUrl(result))
onCleanup(() => {
dead = true
})
})
return { url, refresh: () => setTick((value) => value + 1) }
}
const useMcpToggle = (input: {
sync: ReturnType<typeof useSync>
sdk: ReturnType<typeof useSDK>
language: ReturnType<typeof useLanguage>
}) => {
const [loading, setLoading] = createSignal<string | null>(null)
const toggle = async (name: string) => {
if (loading()) return
setLoading(name)
try {
const status = input.sync.data.mcp[name]
await (status?.status === "connected"
? input.sdk.client.mcp.disconnect({ name })
: input.sdk.client.mcp.connect({ name }))
const result = await input.sdk.client.mcp.status()
if (result.data) input.sync.set("mcp", result.data)
} catch (err) {
showToast({
variant: "error",
title: input.language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
} finally {
setLoading(null)
}
}
return { loading, toggle }
}
export function StatusPopover() {
const sync = useSync()
const sdk = useSDK()
@@ -26,115 +161,35 @@ export function StatusPopover() {
const language = useLanguage()
const navigate = useNavigate()
const [store, setStore] = createStore({
status: {} as Record<string, ServerHealth | undefined>,
loading: null as string | null,
defaultServerUrl: undefined as string | undefined,
})
const fetcher = platform.fetch ?? globalThis.fetch
const servers = createMemo(() => {
const current = server.url
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((x) => x !== current)]
return [current, ...list.filter((item) => item !== current)]
})
const sortedServers = createMemo(() => {
const list = servers()
if (!list.length) return list
const active = server.url
const order = new Map(list.map((url, index) => [url, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff = rank(store.status[a]) - rank(store.status[b])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
async function refreshHealth() {
const results: Record<string, ServerHealth> = {}
await Promise.all(
servers().map(async (url) => {
results[url] = await checkServerHealth(url, fetcher)
}),
)
setStore("status", reconcile(results))
}
createEffect(() => {
servers()
refreshHealth()
const interval = setInterval(refreshHealth, 10_000)
onCleanup(() => clearInterval(interval))
})
const mcpItems = createMemo(() =>
Object.entries(sync.data.mcp ?? {})
.map(([name, status]) => ({ name, status: status.status }))
.sort((a, b) => a.name.localeCompare(b.name)),
)
const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length)
const toggleMcp = async (name: string) => {
if (store.loading) return
setStore("loading", name)
try {
const status = sync.data.mcp[name]
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data)
} catch (err) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
} finally {
setStore("loading", null)
}
}
const health = useServerHealth(servers, fetcher)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health))
const mcp = useMcpToggle({ sync, sdk, language })
const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const lspItems = createMemo(() => sync.data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length)
const plugins = createMemo(() => sync.data.config.plugin ?? [])
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
const overallHealthy = createMemo(() => {
const serverHealthy = server.healthy() === true
const anyMcpIssue = mcpItems().some((m) => m.status !== "connected" && m.status !== "disabled")
const anyMcpIssue = mcpNames().some((name) => {
const status = mcpStatus(name)
return status !== "connected" && status !== "disabled"
})
return serverHealthy && !anyMcpIssue
})
const serverCount = createMemo(() => sortedServers().length)
const refreshDefaultServerUrl = () => {
const result = platform.getDefaultServerUrl?.()
if (!result) {
setStore("defaultServerUrl", undefined)
return
}
if (result instanceof Promise) {
result.then((url) => setStore("defaultServerUrl", url ? normalizeServerUrl(url) : undefined))
return
}
setStore("defaultServerUrl", normalizeServerUrl(result))
}
createEffect(() => {
refreshDefaultServerUrl()
})
return (
<Popover
triggerAs={Button}
@@ -173,7 +228,7 @@ export function StatusPopover() {
>
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{serverCount() > 0 ? `${serverCount()} ` : ""}
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
{language.t("status.popover.tab.servers")}
</Tabs.Trigger>
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
@@ -195,11 +250,7 @@ export function StatusPopover() {
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={sortedServers()}>
{(url) => {
const isActive = () => url === server.url
const isDefault = () => url === store.defaultServerUrl
const status = () => store.status[url]
const isBlocked = () => status()?.healthy === false
const isBlocked = () => health[url]?.healthy === false
return (
<button
type="button"
@@ -217,13 +268,13 @@ export function StatusPopover() {
>
<ServerRow
url={url}
status={status()}
status={health[url]}
dimmed={isBlocked()}
class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
badge={
<Show when={isDefault()}>
<Show when={url === defaultServer.url()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{language.t("common.default")}
</span>
@@ -231,7 +282,7 @@ export function StatusPopover() {
}
>
<div class="flex-1" />
<Show when={isActive()}>
<Show when={url === server.url}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show>
</ServerRow>
@@ -243,7 +294,7 @@ export function StatusPopover() {
<Button
variant="secondary"
class="mt-3 self-start h-8 px-3 py-1.5"
onClick={() => dialog.show(() => <DialogSelectServer />, refreshDefaultServerUrl)}
onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)}
>
{language.t("status.popover.action.manageServers")}
</Button>
@@ -255,39 +306,40 @@ export function StatusPopover() {
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={mcpItems().length > 0}
when={mcpNames().length > 0}
fallback={
<div class="text-14-regular text-text-base text-center my-auto">
{language.t("dialog.mcp.empty")}
</div>
}
>
<For each={mcpItems()}>
{(item) => {
const enabled = () => item.status === "connected"
<For each={mcpNames()}>
{(name) => {
const status = () => mcpStatus(name)
const enabled = () => status() === "connected"
return (
<button
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => toggleMcp(item.name)}
disabled={store.loading === item.name}
onClick={() => mcp.toggle(name)}
disabled={mcp.loading() === name}
>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": item.status === "connected",
"bg-icon-critical-base": item.status === "failed",
"bg-border-weak-base": item.status === "disabled",
"bg-icon-success-base": status() === "connected",
"bg-icon-critical-base": status() === "failed",
"bg-border-weak-base": status() === "disabled",
"bg-icon-warning-base":
item.status === "needs_auth" || item.status === "needs_client_registration",
status() === "needs_auth" || status() === "needs_client_registration",
}}
/>
<span class="text-14-regular text-text-base truncate flex-1">{item.name}</span>
<span class="text-14-regular text-text-base truncate flex-1">{name}</span>
<div onClick={(event) => event.stopPropagation()}>
<Switch
checked={enabled()}
disabled={store.loading === item.name}
onChange={() => toggleMcp(item.name)}
disabled={mcp.loading() === name}
onChange={() => mcp.toggle(name)}
/>
</div>
</button>
@@ -334,23 +386,7 @@ export function StatusPopover() {
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={plugins().length > 0}
fallback={
<div class="text-14-regular text-text-base text-center my-auto">
{(() => {
const value = language.t("dialog.plugins.empty")
const file = "opencode.json"
const parts = value.split(file)
if (parts.length === 1) return value
return (
<>
{parts[0]}
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
{parts.slice(1).join(file)}
</>
)
})()}
</div>
}
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
>
<For each={plugins()}>
{(plugin) => (
+142 -107
View File
@@ -56,6 +56,91 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
},
}
const debugTerminal = (...values: unknown[]) => {
if (!import.meta.env.DEV) return
console.debug("[terminal]", ...values)
}
const useTerminalUiBindings = (input: {
container: HTMLDivElement
term: Term
cleanups: VoidFunction[]
handlePointerDown: () => void
handleLinkClick: (event: MouseEvent) => void
}) => {
const handleCopy = (event: ClipboardEvent) => {
const selection = input.term.getSelection()
if (!selection) return
const clipboard = event.clipboardData
if (!clipboard) return
event.preventDefault()
clipboard.setData("text/plain", selection)
}
const handlePaste = (event: ClipboardEvent) => {
const clipboard = event.clipboardData
const text = clipboard?.getData("text/plain") ?? clipboard?.getData("text") ?? ""
if (!text) return
event.preventDefault()
event.stopPropagation()
input.term.paste(text)
}
const handleTextareaFocus = () => {
input.term.options.cursorBlink = true
}
const handleTextareaBlur = () => {
input.term.options.cursorBlink = false
}
input.container.addEventListener("copy", handleCopy, true)
input.cleanups.push(() => input.container.removeEventListener("copy", handleCopy, true))
input.container.addEventListener("paste", handlePaste, true)
input.cleanups.push(() => input.container.removeEventListener("paste", handlePaste, true))
input.container.addEventListener("pointerdown", input.handlePointerDown)
input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown))
input.container.addEventListener("click", input.handleLinkClick, { capture: true })
input.cleanups.push(() => input.container.removeEventListener("click", input.handleLinkClick, { capture: true }))
input.term.textarea?.addEventListener("focus", handleTextareaFocus)
input.term.textarea?.addEventListener("blur", handleTextareaBlur)
input.cleanups.push(() => input.term.textarea?.removeEventListener("focus", handleTextareaFocus))
input.cleanups.push(() => input.term.textarea?.removeEventListener("blur", handleTextareaBlur))
}
const persistTerminal = (input: {
term: Term | undefined
addon: SerializeAddon | undefined
cursor: number
pty: LocalPTY
onCleanup?: (pty: LocalPTY) => void
}) => {
if (!input.addon || !input.onCleanup || !input.term) return
const buffer = (() => {
try {
return input.addon.serialize()
} catch {
debugTerminal("failed to serialize terminal buffer")
return ""
}
})()
input.onCleanup({
...input.pty,
buffer,
cursor: input.cursor,
rows: input.term.rows,
cols: input.term.cols,
scrollY: input.term.getViewportY(),
})
}
export const Terminal = (props: TerminalProps) => {
const platform = usePlatform()
const sdk = useSDK()
@@ -70,8 +155,6 @@ export const Terminal = (props: TerminalProps) => {
let serializeAddon: SerializeAddon
let fitAddon: FitAddon
let handleResize: () => void
let handleTextareaFocus: () => void
let handleTextareaBlur: () => void
let disposed = false
const cleanups: VoidFunction[] = []
const start =
@@ -84,14 +167,25 @@ export const Terminal = (props: TerminalProps) => {
for (const fn of fns) {
try {
fn()
} catch {
// ignore
} catch (err) {
debugTerminal("cleanup failed", err)
}
}
}
const pushSize = (cols: number, rows: number) => {
return sdk.client.pty
.update({
ptyID: local.pty.id,
size: { cols, rows },
})
.catch((err) => {
debugTerminal("failed to sync terminal size", err)
})
}
const getTerminalColors = (): TerminalColors => {
const mode = theme.mode()
const mode = theme.mode() === "dark" ? "dark" : "light"
const fallback = DEFAULT_TERMINAL_COLORS[mode]
const currentTheme = theme.themes()[theme.themeId()]
if (!currentTheme) return fallback
@@ -130,11 +224,12 @@ export const Terminal = (props: TerminalProps) => {
const t = term
if (!t) return
t.focus()
t.textarea?.focus()
setTimeout(() => t.textarea?.focus(), 0)
}
const handlePointerDown = () => {
const activeElement = document.activeElement
if (activeElement instanceof HTMLElement && activeElement !== container) {
if (activeElement instanceof HTMLElement && activeElement !== container && !container.contains(activeElement)) {
activeElement.blur()
}
focusTerminal()
@@ -185,9 +280,23 @@ export const Terminal = (props: TerminalProps) => {
}
ws = socket
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize =
restore &&
typeof local.pty.cols === "number" &&
Number.isSafeInteger(local.pty.cols) &&
local.pty.cols > 0 &&
typeof local.pty.rows === "number" &&
Number.isSafeInteger(local.pty.rows) &&
local.pty.rows > 0
? { cols: local.pty.cols, rows: local.pty.rows }
: undefined
const t = new mod.Terminal({
cursorBlink: true,
cursorStyle: "bar",
cols: restoreSize?.cols,
rows: restoreSize?.rows,
fontSize: 14,
fontFamily: monoFontFamily(settings.appearance.font()),
allowTransparency: false,
@@ -204,44 +313,11 @@ export const Terminal = (props: TerminalProps) => {
ghostty = g
term = t
const copy = () => {
const selection = t.getSelection()
if (!selection) return false
const body = document.body
if (body) {
const textarea = document.createElement("textarea")
textarea.value = selection
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
body.removeChild(textarea)
if (copied) return true
}
const clipboard = navigator.clipboard
if (clipboard?.writeText) {
clipboard.writeText(selection).catch(() => {})
return true
}
return false
}
t.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase()
if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "c") {
copy()
return true
}
if (event.metaKey && !event.ctrlKey && !event.altKey && key === "c") {
if (!t.hasSelection()) return true
copy()
document.execCommand("copy")
return true
}
@@ -261,51 +337,36 @@ export const Terminal = (props: TerminalProps) => {
serializeAddon = serializer
t.open(container)
container.addEventListener("pointerdown", handlePointerDown)
cleanups.push(() => container.removeEventListener("pointerdown", handlePointerDown))
container.addEventListener("click", handleLinkClick, { capture: true })
cleanups.push(() => container.removeEventListener("click", handleLinkClick, { capture: true }))
handleTextareaFocus = () => {
t.options.cursorBlink = true
}
handleTextareaBlur = () => {
t.options.cursorBlink = false
}
t.textarea?.addEventListener("focus", handleTextareaFocus)
t.textarea?.addEventListener("blur", handleTextareaBlur)
cleanups.push(() => t.textarea?.removeEventListener("focus", handleTextareaFocus))
cleanups.push(() => t.textarea?.removeEventListener("blur", handleTextareaBlur))
useTerminalUiBindings({ container, term: t, cleanups, handlePointerDown, handleLinkClick })
focusTerminal()
fit.fit()
if (local.pty.buffer) {
t.write(local.pty.buffer, () => {
if (local.pty.scrollY) t.scrollToLine(local.pty.scrollY)
})
const startResize = () => {
fit.observeResize()
handleResize = () => fit.fit()
window.addEventListener("resize", handleResize)
cleanups.push(() => window.removeEventListener("resize", handleResize))
}
fit.observeResize()
handleResize = () => fit.fit()
window.addEventListener("resize", handleResize)
cleanups.push(() => window.removeEventListener("resize", handleResize))
if (restore && restoreSize) {
t.write(restore, () => {
fit.fit()
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
startResize()
})
} else {
fit.fit()
if (restore) {
t.write(restore, () => {
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
})
}
startResize()
}
const onResize = t.onResize(async (size) => {
if (socket.readyState === WebSocket.OPEN) {
await sdk.client.pty
.update({
ptyID: local.pty.id,
size: {
cols: size.cols,
rows: size.rows,
},
})
.catch(() => {})
await pushSize(size.cols, size.rows)
}
})
cleanups.push(() => disposeIfDisposable(onResize))
@@ -327,15 +388,7 @@ export const Terminal = (props: TerminalProps) => {
const handleOpen = () => {
local.onConnect?.()
sdk.client.pty
.update({
ptyID: local.pty.id,
size: {
cols: t.cols,
rows: t.rows,
},
})
.catch(() => {})
void pushSize(t.cols, t.rows)
}
socket.addEventListener("open", handleOpen)
cleanups.push(() => socket.removeEventListener("open", handleOpen))
@@ -355,8 +408,8 @@ export const Terminal = (props: TerminalProps) => {
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
cursor = next
}
} catch {
// ignore
} catch (err) {
debugTerminal("invalid websocket control frame", err)
}
return
}
@@ -406,25 +459,7 @@ export const Terminal = (props: TerminalProps) => {
onCleanup(() => {
disposed = true
const t = term
if (serializeAddon && props.onCleanup && t) {
const buffer = (() => {
try {
return serializeAddon.serialize()
} catch {
return ""
}
})()
props.onCleanup({
...local.pty,
buffer,
cursor,
rows: t.rows,
cols: t.cols,
scrollY: t.getViewportY(),
})
}
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
cleanup()
})
+25 -23
View File
@@ -13,6 +13,28 @@ import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { applyPath, backPath, forwardPath } from "./titlebar-history"
type TauriDesktopWindow = {
startDragging?: () => Promise<void>
toggleMaximize?: () => Promise<void>
}
type TauriThemeWindow = {
setTheme?: (theme?: "light" | "dark" | null) => Promise<void>
}
type TauriApi = {
window?: {
getCurrentWindow?: () => TauriDesktopWindow
}
webviewWindow?: {
getCurrentWebviewWindow?: () => TauriThemeWindow
}
}
const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__
const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.()
const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.()
export function Titlebar() {
const layout = useLayout()
const platform = usePlatform()
@@ -82,22 +104,7 @@ export function Titlebar() {
const getWin = () => {
if (platform.platform !== "desktop") return
const tauri = (
window as unknown as {
__TAURI__?: {
window?: {
getCurrentWindow?: () => {
startDragging?: () => Promise<void>
toggleMaximize?: () => Promise<void>
}
}
}
}
).__TAURI__
if (!tauri?.window?.getCurrentWindow) return
return tauri.window.getCurrentWindow()
return currentDesktopWindow()
}
createEffect(() => {
@@ -106,13 +113,8 @@ export function Titlebar() {
const scheme = theme.colorScheme()
const value = scheme === "system" ? null : scheme
const tauri = (window as unknown as { __TAURI__?: { webviewWindow?: { getCurrentWebviewWindow?: () => unknown } } })
.__TAURI__
const get = tauri?.webviewWindow?.getCurrentWebviewWindow
if (!get) return
const win = get() as { setTheme?: (theme?: "light" | "dark" | null) => Promise<void> }
if (!win.setTheme) return
const win = currentThemeWindow()
if (!win?.setTheme) return
void win.setTheme(value).catch(() => undefined)
})
+33 -9
View File
@@ -11,6 +11,7 @@ const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(na
const PALETTE_ID = "command.palette"
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
const SUGGESTED_PREFIX = "suggested."
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new"])
function actionId(id: string) {
if (!id.startsWith(SUGGESTED_PREFIX)) return id
@@ -33,6 +34,11 @@ function signatureFromEvent(event: KeyboardEvent) {
return signature(normalizeKey(event.key), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey)
}
function isAllowedEditableKeybind(id: string | undefined) {
if (!id) return false
return EDITABLE_KEYBIND_IDS.has(actionId(id))
}
export type KeybindConfig = string
export interface Keybind {
@@ -56,6 +62,8 @@ export interface CommandOption {
onHighlight?: () => (() => void) | void
}
type CommandSource = "palette" | "keybind" | "slash"
export type CommandCatalogItem = {
title: string
description?: string
@@ -169,6 +177,14 @@ export function formatKeybind(config: string): string {
return IS_MAC ? parts.join("") : parts.join("+")
}
function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false
if (target.isContentEditable) return true
if (target.closest("[contenteditable='true']")) return true
if (target.closest("input, textarea, select")) return true
return false
}
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
name: "Command",
init: () => {
@@ -275,13 +291,18 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
return map
})
const run = (id: string, source?: "palette" | "keybind" | "slash") => {
const optionMap = createMemo(() => {
const map = new Map<string, CommandOption>()
for (const option of options()) {
if (option.id === id || option.id === "suggested." + id) {
option.onSelect?.(source)
return
}
map.set(option.id, option)
map.set(actionId(option.id), option)
}
return map
})
const run = (id: string, source?: CommandSource) => {
const option = optionMap().get(id)
option?.onSelect?.(source)
}
const showPalette = () => {
@@ -292,14 +313,17 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
if (suspended() || dialog.active) return
const sig = signatureFromEvent(event)
const isPalette = palette().has(sig)
const option = keymap().get(sig)
if (palette().has(sig)) {
if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id)) return
if (isPalette) {
event.preventDefault()
showPalette()
return
}
const option = keymap().get(sig)
if (!option) return
event.preventDefault()
option.onSelect?.("keybind")
@@ -332,7 +356,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
return {
register,
trigger(id: string, source?: "palette" | "keybind" | "slash") {
trigger(id: string, source?: CommandSource) {
run(id, source)
},
keybind(id: string) {
@@ -351,7 +375,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
},
show: showPalette,
keybinds(enabled: boolean) {
setStore("suspendCount", (count) => count + (enabled ? -1 : 1))
setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1)))
},
suspended,
get catalog() {
+41
View File
@@ -109,4 +109,45 @@ describe("comments session indexing", () => {
dispose()
})
})
test("remove keeps focus when same comment id exists in another file", () => {
createRoot((dispose) => {
const comments = createCommentSessionForTest({
"a.ts": [line("a.ts", "shared", 10)],
"b.ts": [line("b.ts", "shared", 20)],
})
comments.setFocus({ file: "b.ts", id: "shared" })
comments.remove("a.ts", "shared")
expect(comments.focus()).toEqual({ file: "b.ts", id: "shared" })
expect(comments.list("a.ts")).toEqual([])
expect(comments.list("b.ts").map((item) => item.id)).toEqual(["shared"])
dispose()
})
})
test("setFocus and setActive updater callbacks receive current state", () => {
createRoot((dispose) => {
const comments = createCommentSessionForTest()
comments.setFocus({ file: "a.ts", id: "a1" })
comments.setFocus((current) => {
expect(current).toEqual({ file: "a.ts", id: "a1" })
return { file: "b.ts", id: "b1" }
})
comments.setActive({ file: "c.ts", id: "c1" })
comments.setActive((current) => {
expect(current).toEqual({ file: "c.ts", id: "c1" })
return null
})
expect(comments.focus()).toEqual({ file: "b.ts", id: "b1" })
expect(comments.active()).toBeNull()
dispose()
})
})
})
+32 -29
View File
@@ -1,9 +1,10 @@
import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams } from "@solidjs/router"
import { Persist, persisted } from "@/utils/persist"
import { createScopedCache } from "@/utils/scoped-cache"
import { uuid } from "@/utils/uuid"
import type { SelectedLineRange } from "@/context/file"
export type LineComment = {
@@ -19,6 +20,19 @@ type CommentFocus = { file: string; id: string }
const WORKSPACE_KEY = "__workspace__"
const MAX_COMMENT_SESSIONS = 20
function sessionKey(dir: string, id: string | undefined) {
return `${dir}\n${id ?? WORKSPACE_KEY}`
}
function decodeSessionKey(key: string) {
const split = key.lastIndexOf("\n")
if (split < 0) return { dir: key, id: WORKSPACE_KEY }
return {
dir: key.slice(0, split),
id: key.slice(split + 1),
}
}
type CommentStore = {
comments: Record<string, LineComment[]>
}
@@ -30,37 +44,36 @@ function aggregate(comments: Record<string, LineComment[]>) {
.sort((a, b) => a.time - b.time)
}
function insert(items: LineComment[], next: LineComment) {
const index = items.findIndex((item) => item.time > next.time)
if (index < 0) return [...items, next]
return [...items.slice(0, index), next, ...items.slice(index)]
}
function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
const [state, setState] = createStore({
focus: null as CommentFocus | null,
active: null as CommentFocus | null,
all: aggregate(store.comments),
})
const all = () => aggregate(store.comments)
const setRef = (
key: "focus" | "active",
value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null),
) => setState(key, value)
const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
setState("focus", value)
setRef("focus", value)
const setActive = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
setState("active", value)
setRef("active", value)
const list = (file: string) => store.comments[file] ?? []
const add = (input: Omit<LineComment, "id" | "time">) => {
const next: LineComment = {
id: crypto.randomUUID?.() ?? Math.random().toString(16).slice(2),
id: uuid(),
time: Date.now(),
...input,
}
batch(() => {
setStore("comments", input.file, (items) => [...(items ?? []), next])
setState("all", (items) => insert(items, next))
setFocus({ file: input.file, id: next.id })
})
@@ -70,15 +83,13 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
const remove = (file: string, id: string) => {
batch(() => {
setStore("comments", file, (items) => (items ?? []).filter((item) => item.id !== id))
setState("all", (items) => items.filter((item) => !(item.file === file && item.id === id)))
setFocus((current) => (current?.id === id ? null : current))
setFocus((current) => (current?.file === file && current.id === id ? null : current))
})
}
const clear = () => {
batch(() => {
setStore("comments", reconcile({}))
setState("all", [])
setFocus(null)
setActive(null)
})
@@ -86,17 +97,16 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
return {
list,
all: () => state.all,
all,
add,
remove,
clear,
focus: () => state.focus,
setFocus,
clearFocus: () => setFocus(null),
clearFocus: () => setRef("focus", null),
active: () => state.active,
setActive,
clearActive: () => setActive(null),
reindex: () => setState("all", aggregate(store.comments)),
clearActive: () => setRef("active", null),
}
}
@@ -116,11 +126,6 @@ function createCommentSession(dir: string, id: string | undefined) {
)
const session = createCommentSessionState(store, setStore)
createEffect(() => {
if (!ready()) return
session.reindex()
})
return {
ready,
list: session.list,
@@ -144,11 +149,9 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
const params = useParams()
const cache = createScopedCache(
(key) => {
const split = key.lastIndexOf("\n")
const dir = split >= 0 ? key.slice(0, split) : key
const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
const decoded = decodeSessionKey(key)
return createRoot((dispose) => ({
value: createCommentSession(dir, id === WORKSPACE_KEY ? undefined : id),
value: createCommentSession(decoded.dir, decoded.id === WORKSPACE_KEY ? undefined : decoded.id),
dispose,
}))
},
@@ -161,7 +164,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
onCleanup(() => cache.clear())
const load = (dir: string, id: string | undefined) => {
const key = `${dir}\n${id ?? WORKSPACE_KEY}`
const key = sessionKey(dir, id)
return cache.get(key).value
}
+57 -44
View File
@@ -43,6 +43,12 @@ export {
touchFileContent,
}
function errorMessage(error: unknown) {
if (error instanceof Error && error.message) return error.message
if (typeof error === "string" && error) return error
return "Unknown error"
}
export const { use: useFile, provider: FileProvider } = createSimpleContext({
name: "File",
gate: false,
@@ -110,6 +116,45 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setStore("file", file, { path: file, name: getFilename(file) })
}
const setLoading = (file: string) => {
setStore(
"file",
file,
produce((draft) => {
draft.loading = true
draft.error = undefined
}),
)
}
const setLoaded = (file: string, content: FileState["content"]) => {
setStore(
"file",
file,
produce((draft) => {
draft.loaded = true
draft.loading = false
draft.content = content
}),
)
}
const setLoadError = (file: string, message: string) => {
setStore(
"file",
file,
produce((draft) => {
draft.loading = false
draft.error = message
}),
)
showToast({
variant: "error",
title: language.t("toast.file.loadFailed.title"),
description: message,
})
}
const load = (input: string, options?: { force?: boolean }) => {
const file = path.normalize(input)
if (!file) return Promise.resolve()
@@ -124,29 +169,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const pending = inflight.get(key)
if (pending) return pending
setStore(
"file",
file,
produce((draft) => {
draft.loading = true
draft.error = undefined
}),
)
setLoading(file)
const promise = sdk.client.file
.read({ path: file })
.then((x) => {
if (scope() !== directory) return
const content = x.data
setStore(
"file",
file,
produce((draft) => {
draft.loaded = true
draft.loading = false
draft.content = content
}),
)
setLoaded(file, content)
if (!content) return
touchFileContent(file, approxBytes(content))
@@ -154,19 +184,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
})
.catch((e) => {
if (scope() !== directory) return
setStore(
"file",
file,
produce((draft) => {
draft.loading = false
draft.error = e.message
}),
)
showToast({
variant: "error",
title: language.t("toast.file.loadFailed.title"),
description: e.message,
})
setLoadError(file, errorMessage(e))
})
.finally(() => {
inflight.delete(key)
@@ -211,21 +229,16 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return state
}
const scrollTop = (input: string) => view().scrollTop(path.normalize(input))
const scrollLeft = (input: string) => view().scrollLeft(path.normalize(input))
const selectedLines = (input: string) => view().selectedLines(path.normalize(input))
const setScrollTop = (input: string, top: number) => {
view().setScrollTop(path.normalize(input), top)
}
const setScrollLeft = (input: string, left: number) => {
view().setScrollLeft(path.normalize(input), left)
}
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
view().setSelectedLines(path.normalize(input), range)
function withPath(input: string, action: (file: string) => unknown) {
return action(path.normalize(input))
}
const scrollTop = (input: string) => withPath(input, (file) => view().scrollTop(file))
const scrollLeft = (input: string) => withPath(input, (file) => view().scrollLeft(file))
const selectedLines = (input: string) => withPath(input, (file) => view().selectedLines(file))
const setScrollTop = (input: string, top: number) => withPath(input, (file) => view().setScrollTop(file, top))
const setScrollLeft = (input: string, left: number) => withPath(input, (file) => view().setScrollLeft(file, left))
const setSelectedLines = (input: string, range: SelectedLineRange | null) =>
withPath(input, (file) => view().setSelectedLines(file, range))
onCleanup(() => {
stop()
+31 -21
View File
@@ -23,6 +23,16 @@ function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
}
}
function equalSelectedLines(a: SelectedLineRange | null | undefined, b: SelectedLineRange | null | undefined) {
if (!a && !b) return true
if (!a || !b) return false
const left = normalizeSelectedLines(a)
const right = normalizeSelectedLines(b)
return (
left.start === right.start && left.end === right.end && left.side === right.side && left.endSide === right.endSide
)
}
function createViewSession(dir: string, id: string | undefined) {
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
@@ -65,36 +75,36 @@ function createViewSession(dir: string, id: string | undefined) {
const selectedLines = (path: string) => view.file[path]?.selectedLines
const setScrollTop = (path: string, top: number) => {
setView("file", path, (current) => {
if (current?.scrollTop === top) return current
return {
...(current ?? {}),
scrollTop: top,
}
})
setView(
produce((draft) => {
const file = draft.file[path] ?? (draft.file[path] = {})
if (file.scrollTop === top) return
file.scrollTop = top
}),
)
pruneView(path)
}
const setScrollLeft = (path: string, left: number) => {
setView("file", path, (current) => {
if (current?.scrollLeft === left) return current
return {
...(current ?? {}),
scrollLeft: left,
}
})
setView(
produce((draft) => {
const file = draft.file[path] ?? (draft.file[path] = {})
if (file.scrollLeft === left) return
file.scrollLeft = left
}),
)
pruneView(path)
}
const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
const next = range ? normalizeSelectedLines(range) : null
setView("file", path, (current) => {
if (current?.selectedLines === next) return current
return {
...(current ?? {}),
selectedLines: next,
}
})
setView(
produce((draft) => {
const file = draft.file[path] ?? (draft.file[path] = {})
if (equalSelectedLines(file.selectedLines, next)) return
file.selectedLines = next
}),
)
pruneView(path)
}
+15 -7
View File
@@ -31,9 +31,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
}>()
type Queued = { directory: string; payload: Event }
const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8
let queue: Array<Queued | undefined> = []
let buffer: Array<Queued | undefined> = []
let queue: Queued[] = []
let buffer: Queued[] = []
const coalesced = new Map<string, number>()
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
@@ -62,7 +64,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
last = Date.now()
batch(() => {
for (const event of events) {
if (!event) continue
emitter.emit(event.directory, event.payload)
}
})
@@ -73,9 +74,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const schedule = () => {
if (timer) return
const elapsed = Date.now() - last
timer = setTimeout(flush, Math.max(0, 16 - elapsed))
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
}
let streamErrorLogged = false
void (async () => {
const events = await eventSdk.global.event()
let yielded = Date.now()
@@ -86,20 +89,25 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = undefined
queue[i] = { directory, payload }
continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
if (Date.now() - yielded < 8) continue
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
})()
.finally(flush)
.catch(() => undefined)
.catch((error) => {
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream failed", error)
})
onCleanup(() => {
abort.abort()
+42 -28
View File
@@ -47,6 +47,20 @@ type GlobalStore = {
reload: undefined | "pending" | "complete"
}
function errorMessage(error: unknown) {
if (error instanceof Error && error.message) return error.message
if (typeof error === "string" && error) return error
return "Unknown error"
}
function setDevStats(value: {
activeDirectoryStores: number
evictions: number
loadSessionsFullFetchFallback: number
}) {
;(globalThis as { __OPENCODE_GLOBAL_SYNC_STATS?: typeof value }).__OPENCODE_GLOBAL_SYNC_STATS = value
}
function createGlobalSync() {
const globalSDK = useGlobalSDK()
const platform = usePlatform()
@@ -81,19 +95,11 @@ function createGlobalSync() {
const updateStats = (activeDirectoryStores: number) => {
if (!import.meta.env.DEV) return
;(
globalThis as {
__OPENCODE_GLOBAL_SYNC_STATS?: {
activeDirectoryStores: number
evictions: number
loadSessionsFullFetchFallback: number
}
}
).__OPENCODE_GLOBAL_SYNC_STATS = {
setDevStats({
activeDirectoryStores,
evictions: stats.evictions,
loadSessionsFullFetchFallback: stats.loadSessionsFallback,
}
})
}
const paused = () => untrack(() => globalStore.reload) !== undefined
@@ -204,7 +210,10 @@ function createGlobalSync() {
.catch((err) => {
console.error("Failed to load sessions", err)
const project = getFilename(directory)
showToast({ title: language.t("toast.session.listFailed.title", { project }), description: err.message })
showToast({
title: language.t("toast.session.listFailed.title", { project }),
description: errorMessage(err),
})
})
sessionLoads.set(directory, promise)
@@ -307,12 +316,28 @@ function createGlobalSync() {
void bootstrap()
})
function projectMeta(directory: string, patch: ProjectMeta) {
children.projectMeta(directory, patch)
const projectApi = {
loadSessions,
meta(directory: string, patch: ProjectMeta) {
children.projectMeta(directory, patch)
},
icon(directory: string, value: string | undefined) {
children.projectIcon(directory, value)
},
}
function projectIcon(directory: string, value: string | undefined) {
children.projectIcon(directory, value)
const updateConfig = async (config: Config) => {
setGlobalStore("reload", "pending")
return globalSDK.client.global.config
.update({ config })
.then(bootstrap)
.then(() => {
setGlobalStore("reload", "complete")
})
.catch((error) => {
setGlobalStore("reload", undefined)
throw error
})
}
return {
@@ -326,19 +351,8 @@ function createGlobalSync() {
},
child: children.child,
bootstrap,
updateConfig: (config: Config) => {
setGlobalStore("reload", "pending")
return globalSDK.client.global.config.update({ config }).finally(() => {
setTimeout(() => {
setGlobalStore("reload", "complete")
}, 1000)
})
},
project: {
loadSessions,
meta: projectMeta,
icon: projectIcon,
},
updateConfig,
project: projectApi,
}
}
@@ -233,6 +233,7 @@ export function applyDirectoryEvent(input: {
}
case "vcs.branch.updated": {
const props = event.properties as { branch: string }
if (input.store.vcs?.branch === props.branch) break
const next = { branch: props.branch }
input.setStore("vcs", next)
if (input.vcsCache) input.vcsCache.setStore("value", next)
+56 -49
View File
@@ -119,9 +119,7 @@ function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; p
const highlights = releases.slice(start, end).flatMap((release) => release.highlights)
const seen = new Set<string>()
const unique = highlights.filter((highlight) => {
const key = [highlight.title, highlight.description, highlight.media?.type ?? "", highlight.media?.src ?? ""].join(
"\n",
)
const key = dedupeKey(highlight)
if (seen.has(key)) return false
seen.add(key)
return true
@@ -129,6 +127,16 @@ function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; p
return unique.slice(0, 5)
}
function dedupeKey(highlight: Highlight) {
return [highlight.title, highlight.description, highlight.media?.type ?? "", highlight.media?.src ?? ""].join("\n")
}
function loadReleaseHighlights(value: unknown, current?: string, previous?: string) {
const releases = parseChangelog(value)
if (!releases?.length) return []
return sliceHighlights({ releases, current, previous })
}
export const { use: useHighlights, provider: HighlightsProvider } = createSimpleContext({
name: "Highlights",
gate: false,
@@ -140,14 +148,57 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
const [from, setFrom] = createSignal<string | undefined>(undefined)
const [to, setTo] = createSignal<string | undefined>(undefined)
const [timer, setTimer] = createSignal<ReturnType<typeof setTimeout> | undefined>(undefined)
const state = { started: false }
let timer: ReturnType<typeof setTimeout> | undefined
const clearTimer = () => {
if (timer === undefined) return
clearTimeout(timer)
timer = undefined
}
const markSeen = () => {
if (!platform.version) return
setStore("version", platform.version)
}
const start = (previous: string) => {
if (!settings.general.releaseNotes()) {
markSeen()
return
}
const fetcher = platform.fetch ?? fetch
const controller = new AbortController()
onCleanup(() => {
controller.abort()
clearTimer()
})
fetcher(CHANGELOG_URL, {
signal: controller.signal,
headers: { Accept: "application/json" },
})
.then((response) => (response.ok ? (response.json() as Promise<unknown>) : undefined))
.then((json) => {
if (!json) return
const highlights = loadReleaseHighlights(json, platform.version, previous)
if (controller.signal.aborted) return
if (highlights.length === 0) {
markSeen()
return
}
timer = setTimeout(() => {
timer = undefined
markSeen()
dialog.show(() => <DialogReleaseNotes highlights={highlights} />)
}, 500)
})
.catch(() => undefined)
}
createEffect(() => {
if (state.started) return
if (!ready()) return
@@ -165,51 +216,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple
setFrom(previous)
setTo(platform.version)
if (!settings.general.releaseNotes()) {
markSeen()
return
}
const fetcher = platform.fetch ?? fetch
const controller = new AbortController()
onCleanup(() => {
controller.abort()
const id = timer()
if (id === undefined) return
clearTimeout(id)
})
fetcher(CHANGELOG_URL, {
signal: controller.signal,
headers: { Accept: "application/json" },
})
.then((response) => (response.ok ? (response.json() as Promise<unknown>) : undefined))
.then((json) => {
if (!json) return
const releases = parseChangelog(json)
if (!releases) return
if (releases.length === 0) return
const highlights = sliceHighlights({
releases,
current: platform.version,
previous,
})
if (controller.signal.aborted) return
if (highlights.length === 0) {
markSeen()
return
}
const timer = setTimeout(() => {
markSeen()
dialog.show(() => <DialogReleaseNotes highlights={highlights} />)
}, 500)
setTimer(timer)
})
.catch(() => undefined)
start(previous)
})
return {
+68 -79
View File
@@ -76,6 +76,66 @@ const LOCALES: readonly Locale[] = [
"th",
]
const LABEL_KEY: Record<Locale, keyof Dictionary> = {
en: "language.en",
zh: "language.zh",
zht: "language.zht",
ko: "language.ko",
de: "language.de",
es: "language.es",
fr: "language.fr",
da: "language.da",
ja: "language.ja",
pl: "language.pl",
ru: "language.ru",
ar: "language.ar",
no: "language.no",
br: "language.br",
th: "language.th",
bs: "language.bs",
}
const base = i18n.flatten({ ...en, ...uiEn })
const DICT: Record<Locale, Dictionary> = {
en: base,
zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) },
zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) },
ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) },
de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) },
es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) },
fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) },
da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) },
ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) },
pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) },
ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) },
ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) },
no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) },
br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) },
th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) },
bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) },
}
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
{ locale: "zht", match: (language) => language.startsWith("zh") && language.includes("hant") },
{ locale: "zh", match: (language) => language.startsWith("zh") },
{ locale: "ko", match: (language) => language.startsWith("ko") },
{ locale: "de", match: (language) => language.startsWith("de") },
{ locale: "es", match: (language) => language.startsWith("es") },
{ locale: "fr", match: (language) => language.startsWith("fr") },
{ locale: "da", match: (language) => language.startsWith("da") },
{ locale: "ja", match: (language) => language.startsWith("ja") },
{ locale: "pl", match: (language) => language.startsWith("pl") },
{ locale: "ru", match: (language) => language.startsWith("ru") },
{ locale: "ar", match: (language) => language.startsWith("ar") },
{
locale: "no",
match: (language) => language.startsWith("no") || language.startsWith("nb") || language.startsWith("nn"),
},
{ locale: "br", match: (language) => language.startsWith("pt") },
{ locale: "th", match: (language) => language.startsWith("th") },
{ locale: "bs", match: (language) => language.startsWith("bs") },
]
type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
zh,
@@ -102,28 +162,9 @@ function detectLocale(): Locale {
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
for (const language of languages) {
if (!language) continue
if (language.toLowerCase().startsWith("zh")) {
if (language.toLowerCase().includes("hant")) return "zht"
return "zh"
}
if (language.toLowerCase().startsWith("ko")) return "ko"
if (language.toLowerCase().startsWith("de")) return "de"
if (language.toLowerCase().startsWith("es")) return "es"
if (language.toLowerCase().startsWith("fr")) return "fr"
if (language.toLowerCase().startsWith("da")) return "da"
if (language.toLowerCase().startsWith("ja")) return "ja"
if (language.toLowerCase().startsWith("pl")) return "pl"
if (language.toLowerCase().startsWith("ru")) return "ru"
if (language.toLowerCase().startsWith("ar")) return "ar"
if (
language.toLowerCase().startsWith("no") ||
language.toLowerCase().startsWith("nb") ||
language.toLowerCase().startsWith("nn")
)
return "no"
if (language.toLowerCase().startsWith("pt")) return "br"
if (language.toLowerCase().startsWith("th")) return "th"
if (language.toLowerCase().startsWith("bs")) return "bs"
const normalized = language.toLowerCase()
const match = localeMatchers.find((entry) => entry.match(normalized))
if (match) return match.locale
}
return "en"
@@ -139,24 +180,9 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
}),
)
const locale = createMemo<Locale>(() => {
if (store.locale === "zh") return "zh"
if (store.locale === "zht") return "zht"
if (store.locale === "ko") return "ko"
if (store.locale === "de") return "de"
if (store.locale === "es") return "es"
if (store.locale === "fr") return "fr"
if (store.locale === "da") return "da"
if (store.locale === "ja") return "ja"
if (store.locale === "pl") return "pl"
if (store.locale === "ru") return "ru"
if (store.locale === "ar") return "ar"
if (store.locale === "no") return "no"
if (store.locale === "br") return "br"
if (store.locale === "th") return "th"
if (store.locale === "bs") return "bs"
return "en"
})
const locale = createMemo<Locale>(() =>
LOCALES.includes(store.locale as Locale) ? (store.locale as Locale) : "en",
)
createEffect(() => {
const current = locale()
@@ -164,48 +190,11 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
setStore("locale", current)
})
const base = i18n.flatten({ ...en, ...uiEn })
const dict = createMemo<Dictionary>(() => {
if (locale() === "en") return base
if (locale() === "zh") return { ...base, ...i18n.flatten({ ...zh, ...uiZh }) }
if (locale() === "zht") return { ...base, ...i18n.flatten({ ...zht, ...uiZht }) }
if (locale() === "de") return { ...base, ...i18n.flatten({ ...de, ...uiDe }) }
if (locale() === "es") return { ...base, ...i18n.flatten({ ...es, ...uiEs }) }
if (locale() === "fr") return { ...base, ...i18n.flatten({ ...fr, ...uiFr }) }
if (locale() === "da") return { ...base, ...i18n.flatten({ ...da, ...uiDa }) }
if (locale() === "ja") return { ...base, ...i18n.flatten({ ...ja, ...uiJa }) }
if (locale() === "pl") return { ...base, ...i18n.flatten({ ...pl, ...uiPl }) }
if (locale() === "ru") return { ...base, ...i18n.flatten({ ...ru, ...uiRu }) }
if (locale() === "ar") return { ...base, ...i18n.flatten({ ...ar, ...uiAr }) }
if (locale() === "no") return { ...base, ...i18n.flatten({ ...no, ...uiNo }) }
if (locale() === "br") return { ...base, ...i18n.flatten({ ...br, ...uiBr }) }
if (locale() === "th") return { ...base, ...i18n.flatten({ ...th, ...uiTh }) }
if (locale() === "bs") return { ...base, ...i18n.flatten({ ...bs, ...uiBs }) }
return { ...base, ...i18n.flatten({ ...ko, ...uiKo }) }
})
const dict = createMemo<Dictionary>(() => DICT[locale()])
const t = i18n.translator(dict, i18n.resolveTemplate)
const labelKey: Record<Locale, keyof Dictionary> = {
en: "language.en",
zh: "language.zh",
zht: "language.zht",
ko: "language.ko",
de: "language.de",
es: "language.es",
fr: "language.fr",
da: "language.da",
ja: "language.ja",
pl: "language.pl",
ru: "language.ru",
ar: "language.ar",
no: "language.no",
br: "language.br",
th: "language.th",
bs: "language.bs",
}
const label = (value: Locale) => t(labelKey[value])
const label = (value: Locale) => t(LABEL_KEY[value])
createEffect(() => {
if (typeof document !== "object") return
+46 -65
View File
@@ -4,12 +4,16 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk"
import { useServer } from "./server"
import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2"
import { Persist, persisted, removePersisted } from "@/utils/persist"
import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
const DEFAULT_PANEL_WIDTH = 344
const DEFAULT_SESSION_WIDTH = 600
const DEFAULT_TERMINAL_HEIGHT = 280
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
export function getAvatarColors(key?: string) {
@@ -84,12 +88,21 @@ export function pruneSessionKeys(input: {
.slice(input.max)
}
function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
const all = current?.all ?? []
if (tab === "review") return { all: all.filter((x) => x !== "review"), active: tab }
if (tab === "context") return { all: [tab, ...all.filter((x) => x !== tab)], active: tab }
if (!all.includes(tab)) return { all: [...all, tab], active: tab }
return { all, active: tab }
}
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
init: () => {
const globalSdk = useGlobalSDK()
const globalSync = useGlobalSync()
const server = useServer()
const platform = usePlatform()
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
@@ -114,11 +127,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
if (!isRecord(fileTree)) return fileTree
if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree
const width = typeof fileTree.width === "number" ? fileTree.width : 344
const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_PANEL_WIDTH
return {
...fileTree,
opened: true,
width: width === 260 ? 344 : width,
width: width === 260 ? DEFAULT_PANEL_WIDTH : width,
tab: "changes",
}
})()
@@ -149,12 +162,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
createStore({
sidebar: {
opened: false,
width: 344,
width: DEFAULT_PANEL_WIDTH,
workspaces: {} as Record<string, boolean>,
workspacesDefault: false,
},
terminal: {
height: 280,
height: DEFAULT_TERMINAL_HEIGHT,
opened: false,
},
review: {
@@ -163,11 +176,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
fileTree: {
opened: true,
width: 344,
width: DEFAULT_PANEL_WIDTH,
tab: "changes" as "changes" | "all",
},
session: {
width: 600,
width: DEFAULT_SESSION_WIDTH,
},
mobileSidebar: {
opened: false,
@@ -182,8 +195,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const MAX_SESSION_KEYS = 50
const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
const meta = { active: undefined as string | undefined, pruned: false }
const used = new Map<string, number>()
const usage = {
active: undefined as string | undefined,
pruned: false,
used: new Map<string, number>(),
}
const SESSION_STATE_KEYS = [
{ key: "prompt", legacy: "prompt", version: "v2" },
@@ -200,10 +216,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
for (const entry of SESSION_STATE_KEYS) {
const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key)
void removePersisted(target)
void removePersisted(target, platform)
const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
void removePersisted({ key: legacyKey })
void removePersisted({ key: legacyKey }, platform)
}
}
}
@@ -212,7 +228,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const drop = pruneSessionKeys({
keep,
max: MAX_SESSION_KEYS,
used,
used: usage.used,
view: Object.keys(store.sessionView),
tabs: Object.keys(store.sessionTabs),
})
@@ -231,18 +247,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
dropSessionState(drop)
for (const key of drop) {
used.delete(key)
usage.used.delete(key)
}
}
function touch(sessionKey: string) {
meta.active = sessionKey
used.set(sessionKey, Date.now())
usage.active = sessionKey
usage.used.set(sessionKey, Date.now())
if (!ready()) return
if (meta.pruned) return
if (usage.pruned) return
meta.pruned = true
usage.pruned = true
prune(sessionKey)
}
@@ -251,7 +267,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
onFlush: (sessionKey, next) => {
const current = store.sessionView[sessionKey]
const keep = meta.active ?? sessionKey
const keep = usage.active ?? sessionKey
if (!current) {
setStore("sessionView", sessionKey, { scroll: next })
prune(keep)
@@ -267,10 +283,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
createEffect(() => {
if (!ready()) return
if (meta.pruned) return
const active = meta.active
if (usage.pruned) return
const active = usage.active
if (!active) return
meta.pruned = true
usage.pruned = true
prune(active)
})
@@ -544,32 +560,32 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
fileTree: {
opened: createMemo(() => store.fileTree?.opened ?? true),
width: createMemo(() => store.fileTree?.width ?? 344),
width: createMemo(() => store.fileTree?.width ?? DEFAULT_PANEL_WIDTH),
tab: createMemo(() => store.fileTree?.tab ?? "changes"),
setTab(tab: "changes" | "all") {
if (!store.fileTree) {
setStore("fileTree", { opened: true, width: 344, tab })
setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab })
return
}
setStore("fileTree", "tab", tab)
},
open() {
if (!store.fileTree) {
setStore("fileTree", { opened: true, width: 344, tab: "changes" })
setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
return
}
setStore("fileTree", "opened", true)
},
close() {
if (!store.fileTree) {
setStore("fileTree", { opened: false, width: 344, tab: "changes" })
setStore("fileTree", { opened: false, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
return
}
setStore("fileTree", "opened", false)
},
toggle() {
if (!store.fileTree) {
setStore("fileTree", { opened: true, width: 344, tab: "changes" })
setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
return
}
setStore("fileTree", "opened", (x) => !x)
@@ -583,7 +599,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
},
session: {
width: createMemo(() => store.session?.width ?? 600),
width: createMemo(() => store.session?.width ?? DEFAULT_SESSION_WIDTH),
resize(width: number) {
if (!store.session) {
setStore("session", { width })
@@ -615,7 +631,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
pendingMessage: messageID,
pendingMessageAt: at,
})
prune(meta.active ?? sessionKey)
prune(usage.active ?? sessionKey)
return
}
@@ -656,7 +672,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
function setTerminalOpened(next: boolean) {
const current = store.terminal
if (!current) {
setStore("terminal", { height: 280, opened: next })
setStore("terminal", { height: DEFAULT_TERMINAL_HEIGHT, opened: next })
return
}
@@ -753,43 +769,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
async open(tab: string) {
const session = key()
const current = store.sessionTabs[session] ?? { all: [] }
if (tab === "review") {
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: current.all.filter((x) => x !== "review"), active: tab })
return
}
setStore("sessionTabs", session, "active", tab)
return
}
if (tab === "context") {
const all = [tab, ...current.all.filter((x) => x !== tab)]
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all, active: tab })
return
}
setStore("sessionTabs", session, "all", all)
setStore("sessionTabs", session, "active", tab)
return
}
if (!current.all.includes(tab)) {
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: [tab], active: tab })
return
}
setStore("sessionTabs", session, "all", [...current.all, tab])
setStore("sessionTabs", session, "active", tab)
return
}
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: current.all, active: tab })
return
}
setStore("sessionTabs", session, "active", tab)
const next = nextSessionTabsForOpen(store.sessionTabs[session], tab)
setStore("sessionTabs", session, next)
},
close(tab: string) {
const session = key()
+36 -36
View File
@@ -16,16 +16,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const sdk = useSDK()
const sync = useSync()
const providers = useProviders()
const connected = createMemo(() => new Set(providers.connected().map((provider) => provider.id)))
function isModelValid(model: ModelKey) {
const provider = providers.all().find((x) => x.id === model.providerID)
return (
!!provider?.models[model.modelID] &&
providers
.connected()
.map((p) => p.id)
.includes(model.providerID)
)
return !!provider?.models[model.modelID] && connected().has(model.providerID)
}
function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) {
@@ -36,6 +31,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
}
let setModel: (model: ModelKey | undefined, options?: { recent?: boolean }) => void = () => undefined
const agent = (() => {
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
const [store, setStore] = createStore<{
@@ -75,7 +72,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (!value) return
setStore("current", value.name)
if (value.model)
model.set({
setModel({
providerID: value.model.providerID,
modelID: value.model.modelID,
})
@@ -92,38 +89,37 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model: {},
})
const fallbackModel = createMemo<ModelKey | undefined>(() => {
if (sync.data.config.model) {
const [providerID, modelID] = sync.data.config.model.split("/")
if (isModelValid({ providerID, modelID })) {
return {
providerID,
modelID,
}
}
}
const resolveConfigured = () => {
if (!sync.data.config.model) return
const [providerID, modelID] = sync.data.config.model.split("/")
const key = { providerID, modelID }
if (isModelValid(key)) return key
}
const resolveRecent = () => {
for (const item of models.recent.list()) {
if (isModelValid(item)) {
return item
}
if (isModelValid(item)) return item
}
}
const resolveDefault = () => {
const defaults = providers.default()
for (const p of providers.connected()) {
const configured = defaults[p.id]
for (const provider of providers.connected()) {
const configured = defaults[provider.id]
if (configured) {
const key = { providerID: p.id, modelID: configured }
const key = { providerID: provider.id, modelID: configured }
if (isModelValid(key)) return key
}
const first = Object.values(p.models)[0]
const first = Object.values(provider.models)[0]
if (!first) continue
const key = { providerID: p.id, modelID: first.id }
const key = { providerID: provider.id, modelID: first.id }
if (isModelValid(key)) return key
}
}
return undefined
const fallbackModel = createMemo<ModelKey | undefined>(() => {
return resolveConfigured() ?? resolveRecent() ?? resolveDefault()
})
const current = createMemo(() => {
@@ -163,21 +159,25 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
}
const set = (model: ModelKey | undefined, options?: { recent?: boolean }) => {
batch(() => {
const currentAgent = agent.current()
const next = model ?? fallbackModel()
if (currentAgent) setEphemeral("model", currentAgent.name, next)
if (model) models.setVisibility(model, true)
if (options?.recent && model) models.recent.push(model)
})
}
setModel = set
return {
ready: models.ready,
current,
recent,
list: models.list,
cycle,
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
batch(() => {
const currentAgent = agent.current()
const next = model ?? fallbackModel()
if (currentAgent) setEphemeral("model", currentAgent.name, next)
if (model) models.setVisibility(model, true)
if (options?.recent && model) models.recent.push(model)
})
},
set,
visible(model: ModelKey) {
return models.visible(model)
},
+31 -8
View File
@@ -16,6 +16,12 @@ type Store = {
variant?: Record<string, string | undefined>
}
const RECENT_LIMIT = 5
function modelKey(model: ModelKey) {
return `${model.providerID}:${model.modelID}`
}
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
name: "Models",
init: () => {
@@ -39,10 +45,27 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
),
)
const release = createMemo(
() =>
new Map(
available().map((model) => {
const parsed = DateTime.fromISO(model.release_date)
return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
}),
),
)
const latest = createMemo(() =>
pipe(
available(),
filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6),
filter(
(x) =>
Math.abs(
(release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? DateTime.invalid("invalid"))
.diffNow()
.as("months"),
) < 6,
),
groupBy((x) => x.provider.id),
mapValues((models) =>
pipe(
@@ -61,7 +84,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
),
)
const latestSet = createMemo(() => new Set(latest().map((x) => `${x.providerID}:${x.modelID}`)))
const latestSet = createMemo(() => new Set(latest().map((x) => modelKey(x))))
const visibility = createMemo(() => {
const map = new Map<string, Visibility>()
@@ -82,20 +105,20 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
function update(model: ModelKey, state: Visibility) {
const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
if (index >= 0) {
setStore("user", index, { visibility: state })
setStore("user", index, (current) => ({ ...current, visibility: state }))
return
}
setStore("user", store.user.length, { ...model, visibility: state })
}
const visible = (model: ModelKey) => {
const key = `${model.providerID}:${model.modelID}`
const key = modelKey(model)
const state = visibility().get(key)
if (state === "hide") return false
if (state === "show") return true
if (latestSet().has(key)) return true
const m = find(model)
if (!m?.release_date || !DateTime.fromISO(m.release_date).isValid) return true
const date = release().get(key)
if (!date?.isValid) return true
return false
}
@@ -104,8 +127,8 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext(
}
const push = (model: ModelKey) => {
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
if (uniq.length > 5) uniq.pop()
const uniq = uniqueBy([model, ...store.recent], (x) => `${x.providerID}:${x.modelID}`)
if (uniq.length > RECENT_LIMIT) uniq.pop()
setStore("recent", uniq)
}
+253 -83
View File
@@ -1,5 +1,5 @@
import { createStore } from "solid-js/store"
import { createEffect, createMemo, onCleanup } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSDK } from "./global-sdk"
@@ -13,12 +13,11 @@ import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2"
import { Persist, persisted } from "@/utils/persist"
import { playSound, soundSrc } from "@/utils/sound"
import { buildNotificationIndex } from "./notification-index"
type NotificationBase = {
directory?: string
session?: string
metadata?: any
metadata?: unknown
time: number
viewed: boolean
}
@@ -34,6 +33,21 @@ type ErrorNotification = NotificationBase & {
export type Notification = TurnCompleteNotification | ErrorNotification
type NotificationIndex = {
session: {
all: Record<string, Notification[]>
unseen: Record<string, Notification[]>
unseenCount: Record<string, number>
unseenHasError: Record<string, boolean>
}
project: {
all: Record<string, Notification[]>
unseen: Record<string, Notification[]>
unseenCount: Record<string, number>
unseenHasError: Record<string, boolean>
}
}
const MAX_NOTIFICATIONS = 500
const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30
@@ -44,6 +58,53 @@ function pruneNotifications(list: Notification[]) {
return pruned.slice(pruned.length - MAX_NOTIFICATIONS)
}
function createNotificationIndex(): NotificationIndex {
return {
session: {
all: {},
unseen: {},
unseenCount: {},
unseenHasError: {},
},
project: {
all: {},
unseen: {},
unseenCount: {},
unseenHasError: {},
},
}
}
function buildNotificationIndex(list: Notification[]) {
const index = createNotificationIndex()
list.forEach((notification) => {
if (notification.session) {
const all = index.session.all[notification.session] ?? []
index.session.all[notification.session] = [...all, notification]
if (!notification.viewed) {
const unseen = index.session.unseen[notification.session] ?? []
index.session.unseen[notification.session] = [...unseen, notification]
index.session.unseenCount[notification.session] = unseen.length + 1
if (notification.type === "error") index.session.unseenHasError[notification.session] = true
}
}
if (notification.directory) {
const all = index.project.all[notification.directory] ?? []
index.project.all[notification.directory] = [...all, notification]
if (!notification.viewed) {
const unseen = index.project.unseen[notification.directory] ?? []
index.project.unseen[notification.directory] = [...unseen, notification]
index.project.unseenCount[notification.directory] = unseen.length + 1
if (notification.type === "error") index.project.unseenHasError[notification.directory] = true
}
}
})
return index
}
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification",
init: () => {
@@ -68,21 +129,157 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
list: [] as Notification[],
}),
)
const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list))
const meta = { pruned: false }
const meta = { pruned: false, disposed: false }
const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => {
setIndex(scope, "unseen", key, unseen)
setIndex(scope, "unseenCount", key, unseen.length)
setIndex(
scope,
"unseenHasError",
key,
unseen.some((notification) => notification.type === "error"),
)
}
const appendToIndex = (notification: Notification) => {
if (notification.session) {
setIndex("session", "all", notification.session, (all = []) => [...all, notification])
if (!notification.viewed) {
setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
}
}
if (notification.directory) {
setIndex("project", "all", notification.directory, (all = []) => [...all, notification])
if (!notification.viewed) {
setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification])
setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1)
if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true)
}
}
}
const removeFromIndex = (notification: Notification) => {
if (notification.session) {
setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification))
if (!notification.viewed) {
const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification)
updateUnseen("session", notification.session, unseen)
}
}
if (notification.directory) {
setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification))
if (!notification.viewed) {
const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification)
updateUnseen("project", notification.directory, unseen)
}
}
}
createEffect(() => {
if (!ready()) return
if (meta.pruned) return
meta.pruned = true
setStore("list", pruneNotifications(store.list))
const list = pruneNotifications(store.list)
batch(() => {
setStore("list", list)
setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
})
})
const append = (notification: Notification) => {
setStore("list", (list) => pruneNotifications([...list, notification]))
const list = pruneNotifications([...store.list, notification])
const keep = new Set(list)
const removed = store.list.filter((n) => !keep.has(n))
batch(() => {
if (keep.has(notification)) appendToIndex(notification)
removed.forEach((n) => removeFromIndex(n))
setStore("list", list)
})
}
const index = createMemo(() => buildNotificationIndex(store.list))
const lookup = async (directory: string, sessionID?: string) => {
if (!sessionID) return undefined
const [syncStore] = globalSync.child(directory, { bootstrap: false })
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
if (match.found) return syncStore.session[match.index]
return globalSDK.client.session
.get({ directory, sessionID })
.then((x) => x.data)
.catch(() => undefined)
}
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
const activeDirectory = currentDirectory()
const activeSession = currentSession()
if (!activeDirectory) return false
if (!activeSession) return false
if (!sessionID) return false
if (directory !== activeDirectory) return false
return sessionID === activeSession
}
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (!session) return
if (session.parentID) return
playSound(soundSrc(settings.sounds.agent()))
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
type: "turn-complete",
session: sessionID,
})
const href = `/${base64Encode(directory)}/session/${sessionID}`
if (settings.notifications.agent()) {
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href)
}
})
}
const handleSessionError = (
directory: string,
event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } },
time: number,
) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
if (session?.parentID) return
playSound(soundSrc(settings.sounds.errors()))
const error = "error" in event.properties ? event.properties.error : undefined
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
type: "error",
session: sessionID ?? "global",
error,
})
const description =
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
if (settings.notifications.errors()) {
void platform.notify(language.t("notification.session.error.title"), description, href)
}
})
}
const unsub = globalSDK.event.listen((e) => {
const event = e.details
@@ -90,108 +287,81 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const directory = e.name
const time = Date.now()
const viewed = (sessionID?: string) => {
const activeDirectory = currentDirectory()
const activeSession = currentSession()
if (!activeDirectory) return false
if (!activeSession) return false
if (!sessionID) return false
if (directory !== activeDirectory) return false
return sessionID === activeSession
}
switch (event.type) {
case "session.idle": {
const sessionID = event.properties.sessionID
const [syncStore] = globalSync.child(directory, { bootstrap: false })
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
const session = match.found ? syncStore.session[match.index] : undefined
if (session?.parentID) break
playSound(soundSrc(settings.sounds.agent()))
append({
directory,
time,
viewed: viewed(sessionID),
type: "turn-complete",
session: sessionID,
})
const href = `/${base64Encode(directory)}/session/${sessionID}`
if (settings.notifications.agent()) {
void platform.notify(
language.t("notification.session.responseReady.title"),
session?.title ?? sessionID,
href,
)
}
break
}
case "session.error": {
const sessionID = event.properties.sessionID
const [syncStore] = globalSync.child(directory, { bootstrap: false })
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
playSound(soundSrc(settings.sounds.errors()))
const error = "error" in event.properties ? event.properties.error : undefined
append({
directory,
time,
viewed: viewed(sessionID),
type: "error",
session: sessionID ?? "global",
error,
})
const description =
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
if (settings.notifications.errors()) {
void platform.notify(language.t("notification.session.error.title"), description, href)
}
break
}
if (event.type === "session.idle") {
handleSessionIdle(directory, event, time)
return
}
handleSessionError(directory, event, time)
})
onCleanup(() => {
meta.disposed = true
unsub()
})
onCleanup(unsub)
return {
ready,
session: {
all(session: string) {
return index().session.all.get(session) ?? empty
return index.session.all[session] ?? empty
},
unseen(session: string) {
return index().session.unseen.get(session) ?? empty
return index.session.unseen[session] ?? empty
},
unseenCount(session: string) {
return index().session.unseenCount.get(session) ?? 0
return index.session.unseenCount[session] ?? 0
},
unseenHasError(session: string) {
return index().session.unseenHasError.get(session) ?? false
return index.session.unseenHasError[session] ?? false
},
markViewed(session: string) {
setStore("list", (n) => n.session === session, "viewed", true)
const unseen = index.session.unseen[session] ?? empty
if (!unseen.length) return
const projects = [
...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))),
]
batch(() => {
setStore("list", (n) => n.session === session && !n.viewed, "viewed", true)
updateUnseen("session", session, [])
projects.forEach((directory) => {
const next = (index.project.unseen[directory] ?? empty).filter(
(notification) => notification.session !== session,
)
updateUnseen("project", directory, next)
})
})
},
},
project: {
all(directory: string) {
return index().project.all.get(directory) ?? empty
return index.project.all[directory] ?? empty
},
unseen(directory: string) {
return index().project.unseen.get(directory) ?? empty
return index.project.unseen[directory] ?? empty
},
unseenCount(directory: string) {
return index().project.unseenCount.get(directory) ?? 0
return index.project.unseenCount[directory] ?? 0
},
unseenHasError(directory: string) {
return index().project.unseenHasError.get(directory) ?? false
return index.project.unseenHasError[directory] ?? false
},
markViewed(directory: string) {
setStore("list", (n) => n.directory === directory, "viewed", true)
const unseen = index.project.unseen[directory] ?? empty
if (!unseen.length) return
const sessions = [
...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))),
]
batch(() => {
setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true)
updateUnseen("project", directory, [])
sessions.forEach((session) => {
const next = (index.session.unseen[session] ?? empty).filter(
(notification) => notification.directory !== directory,
)
updateUnseen("session", session, next)
})
})
},
},
}
+14 -2
View File
@@ -33,7 +33,7 @@ function isNonAllowRule(rule: unknown) {
return false
}
function hasAutoAcceptPermissionConfig(permission: unknown) {
function hasPermissionPromptRules(permission: unknown) {
if (!permission) return false
if (typeof permission === "string") return permission !== "allow"
if (typeof permission !== "object") return false
@@ -57,7 +57,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
const directory = decode64(params.dir)
if (!directory) return false
const [store] = globalSync.child(directory)
return hasAutoAcceptPermissionConfig(store.config.permission)
return hasPermissionPromptRules(store.config.permission)
})
const [store, setStore, _, ready] = persisted(
@@ -70,6 +70,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
const MAX_RESPONDED = 1000
const RESPONDED_TTL_MS = 60 * 60 * 1000
const responded = new Map<string, number>()
const enableVersion = new Map<string, number>()
function pruneResponded(now: number) {
for (const [id, ts] of responded) {
@@ -114,6 +115,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
return store.autoAcceptEdits[key] ?? store.autoAcceptEdits[sessionID] ?? false
}
function bumpEnableVersion(sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory)
const next = (enableVersion.get(key) ?? 0) + 1
enableVersion.set(key, next)
return next
}
const unsubscribe = globalSDK.event.listen((e) => {
const event = e.details
if (event?.type !== "permission.asked") return
@@ -128,6 +136,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
function enable(sessionID: string, directory: string) {
const key = acceptKey(sessionID, directory)
const version = bumpEnableVersion(sessionID, directory)
setStore(
produce((draft) => {
draft.autoAcceptEdits[key] = true
@@ -138,6 +147,8 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
globalSDK.client.permission
.list({ directory })
.then((x) => {
if (enableVersion.get(key) !== version) return
if (!isAutoAccepting(sessionID, directory)) return
for (const perm of x.data ?? []) {
if (!perm?.id) continue
if (perm.sessionID !== sessionID) continue
@@ -149,6 +160,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
}
function disable(sessionID: string, directory?: string) {
bumpEnableVersion(sessionID, directory)
const key = directory ? acceptKey(sessionID, directory) : undefined
setStore(
produce((draft) => {
+16 -4
View File
@@ -2,6 +2,12 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
import type { Accessor } from "solid-js"
type PickerPaths = string | string[] | null
type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
type OpenFilePickerOptions = { title?: string; multiple?: boolean }
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
type UpdateInfo = { updateAvailable: boolean; version?: string }
export type Platform = {
/** Platform discriminator */
platform: "web" | "desktop"
@@ -31,19 +37,19 @@ export type Platform = {
notify(title: string, description?: string, href?: string): Promise<void>
/** Open directory picker dialog (native on Tauri, server-backed on web) */
openDirectoryPickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null>
openDirectoryPickerDialog?(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
/** Open native file picker dialog (Tauri only) */
openFilePickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null>
openFilePickerDialog?(opts?: OpenFilePickerOptions): Promise<PickerPaths>
/** Save file picker dialog (Tauri only) */
saveFilePickerDialog?(opts?: { title?: string; defaultPath?: string }): Promise<string | null>
saveFilePickerDialog?(opts?: SaveFilePickerOptions): Promise<string | null>
/** Storage mechanism, defaults to localStorage */
storage?: (name?: string) => SyncStorage | AsyncStorage
/** Check for updates (Tauri only) */
checkUpdate?(): Promise<{ updateAvailable: boolean; version?: string }>
checkUpdate?(): Promise<UpdateInfo>
/** Install updates (Tauri only) */
update?(): Promise<void>
@@ -57,6 +63,12 @@ export type Platform = {
/** Set the default server URL to use on app startup (platform-specific) */
setDefaultServerUrl?(url: string | null): Promise<void> | void
/** Get the configured WSL integration (desktop only) */
getWslEnabled?(): Promise<boolean>
/** Set the configured WSL integration (desktop only) */
setWslEnabled?(config: boolean): Promise<void> | void
/** Get the preferred display backend (desktop only) */
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null
+61 -48
View File
@@ -1,4 +1,4 @@
import { createStore } from "solid-js/store"
import { createStore, type SetStoreFunction } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
@@ -60,27 +60,23 @@ function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
)
}
function isPartEqual(partA: ContentPart, partB: ContentPart) {
switch (partA.type) {
case "text":
return partB.type === "text" && partA.content === partB.content
case "file":
return partB.type === "file" && partA.path === partB.path && isSelectionEqual(partA.selection, partB.selection)
case "agent":
return partB.type === "agent" && partA.name === partB.name
case "image":
return partB.type === "image" && partA.id === partB.id
}
}
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
if (promptA.length !== promptB.length) return false
for (let i = 0; i < promptA.length; i++) {
const partA = promptA[i]
const partB = promptB[i]
if (partA.type !== partB.type) return false
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
return false
}
if (partA.type === "file") {
const fileA = partA as FileAttachmentPart
const fileB = partB as FileAttachmentPart
if (fileA.path !== fileB.path) return false
if (!isSelectionEqual(fileA.selection, fileB.selection)) return false
}
if (partA.type === "agent" && partA.name !== (partB as AgentPart).name) {
return false
}
if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) {
return false
}
if (!isPartEqual(promptA[i], promptB[i])) return false
}
return true
}
@@ -104,6 +100,48 @@ function clonePrompt(prompt: Prompt): Prompt {
return prompt.map(clonePart)
}
function contextItemKey(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
const end = item.selection?.endLine
const key = `${item.type}:${item.path}:${start}:${end}`
if (item.commentID) {
return `${key}:c=${item.commentID}`
}
const comment = item.comment?.trim()
if (!comment) return key
const digest = checksum(comment) ?? comment
return `${key}:c=${digest.slice(0, 8)}`
}
function createPromptActions(
setStore: SetStoreFunction<{
prompt: Prompt
cursor?: number
context: {
items: (ContextItem & { key: string })[]
}
}>,
) {
return {
set(prompt: Prompt, cursorPosition?: number) {
const next = clonePrompt(prompt)
batch(() => {
setStore("prompt", next)
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
})
},
reset() {
batch(() => {
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
setStore("cursor", 0)
})
},
}
}
const WORKSPACE_KEY = "__workspace__"
const MAX_PROMPT_SESSIONS = 20
@@ -134,21 +172,7 @@ function createPromptSession(dir: string, id: string | undefined) {
}),
)
function keyForItem(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
const end = item.selection?.endLine
const key = `${item.type}:${item.path}:${start}:${end}`
if (item.commentID) {
return `${key}:c=${item.commentID}`
}
const comment = item.comment?.trim()
if (!comment) return key
const digest = checksum(comment) ?? comment
return `${key}:c=${digest.slice(0, 8)}`
}
const actions = createPromptActions(setStore)
return {
ready,
@@ -158,7 +182,7 @@ function createPromptSession(dir: string, id: string | undefined) {
context: {
items: createMemo(() => store.context.items),
add(item: ContextItem) {
const key = keyForItem(item)
const key = contextItemKey(item)
if (store.context.items.find((x) => x.key === key)) return
setStore("context", "items", (items) => [...items, { key, ...item }])
},
@@ -166,19 +190,8 @@ function createPromptSession(dir: string, id: string | undefined) {
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
},
},
set(prompt: Prompt, cursorPosition?: number) {
const next = clonePrompt(prompt)
batch(() => {
setStore("prompt", next)
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
})
},
reset() {
batch(() => {
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
setStore("cursor", 0)
})
},
set: actions.set,
reset: actions.reset,
}
}
+5 -3
View File
@@ -5,6 +5,10 @@ import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
import { useGlobalSDK } from "./global-sdk"
import { usePlatform } from "./platform"
type SDKEventMap = {
[key in Event["type"]]: Extract<Event, { type: key }>
}
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: { directory: Accessor<string> }) => {
@@ -21,9 +25,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
}),
)
const emitter = createGlobalEmitter<{
[key in Event["type"]]: Extract<Event, { type: key }>
}>()
const emitter = createGlobalEmitter<SDKEventMap>()
createEffect(() => {
const unsub = globalSDK.event.on(directory(), (event) => {
+71 -64
View File
@@ -6,6 +6,7 @@ import { Persist, persisted } from "@/utils/persist"
import { checkServerHealth } from "@/utils/server-health"
type StoredProject = { worktree: string; expanded: boolean }
const HEALTH_POLL_INTERVAL_MS = 10_000
export function normalizeServerUrl(input: string) {
const trimmed = input.trim()
@@ -48,24 +49,38 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const healthy = () => state.healthy
function setActive(input: string) {
const url = normalizeServerUrl(input)
if (!url) return
setState("active", url)
const defaultUrl = () => normalizeServerUrl(props.defaultUrl)
function reconcileStartup() {
const fallback = defaultUrl()
if (!fallback) return
const previousSidecarUrl = normalizeServerUrl(store.currentSidecarUrl)
const list = previousSidecarUrl ? store.list.filter((url) => url !== previousSidecarUrl) : store.list
if (!props.isSidecar) {
batch(() => {
setStore("list", list)
if (store.currentSidecarUrl) setStore("currentSidecarUrl", "")
setState("active", fallback)
})
return
}
const nextList = list.includes(fallback) ? list : [...list, fallback]
batch(() => {
setStore("list", nextList)
setStore("currentSidecarUrl", fallback)
setState("active", fallback)
})
}
function add(input: string) {
const url = normalizeServerUrl(input)
if (!url) return
const fallback = normalizeServerUrl(props.defaultUrl)
if (fallback && url === fallback) {
function updateServerList(url: string, remove = false) {
if (remove) {
const list = store.list.filter((x) => x !== url)
const next = state.active === url ? (list[0] ?? defaultUrl() ?? "") : state.active
batch(() => {
if (!store.list.includes(url)) {
// Add the fallback url to the list if it's not already in the list
setStore("list", store.list.length, url)
}
setState("active", url)
setStore("list", list)
setState("active", next)
})
return
}
@@ -78,51 +93,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
})
}
function remove(input: string) {
const url = normalizeServerUrl(input)
if (!url) return
const list = store.list.filter((x) => x !== url)
const next = state.active === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : state.active
batch(() => {
setStore("list", list)
setState("active", next)
})
}
createEffect(() => {
if (!ready()) return
if (state.active) return
const url = normalizeServerUrl(props.defaultUrl)
if (!url) return
batch(() => {
// Remove the previous startup sidecar url
if (store.currentSidecarUrl) {
remove(store.currentSidecarUrl)
}
// Add the new sidecar url
if (props.isSidecar && props.defaultUrl) {
add(props.defaultUrl)
setStore("currentSidecarUrl", props.defaultUrl)
}
setState("active", url)
})
})
const isReady = createMemo(() => ready() && !!state.active)
const fetcher = platform.fetch ?? globalThis.fetch
const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy)
createEffect(() => {
const url = state.active
if (!url) return
setState("healthy", undefined)
function startHealthPolling(url: string) {
let alive = true
let busy = false
@@ -140,12 +111,48 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
}
run()
const interval = setInterval(run, 10_000)
onCleanup(() => {
const interval = setInterval(run, HEALTH_POLL_INTERVAL_MS)
return () => {
alive = false
clearInterval(interval)
})
}
}
function setActive(input: string) {
const url = normalizeServerUrl(input)
if (!url) return
setState("active", url)
}
function add(input: string) {
const url = normalizeServerUrl(input)
if (!url) return
updateServerList(url)
}
function remove(input: string) {
const url = normalizeServerUrl(input)
if (!url) return
updateServerList(url, true)
}
createEffect(() => {
if (!ready()) return
if (state.active) return
reconcileStartup()
})
const isReady = createMemo(() => ready() && !!state.active)
const fetcher = platform.fetch ?? globalThis.fetch
const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy)
createEffect(() => {
const url = state.active
if (!url) return
setState("healthy", undefined)
onCleanup(startHealthPolling(url))
})
const origin = createMemo(() => projectsKey(state.active))
+22 -13
View File
@@ -85,6 +85,10 @@ export function monoFontFamily(font: string | undefined) {
return monoFonts[font ?? defaultSettings.appearance.font] ?? monoFonts[defaultSettings.appearance.font]
}
function withFallback<T>(read: () => T | undefined, fallback: T) {
return createMemo(() => read() ?? fallback)
}
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
name: "Settings",
init: () => {
@@ -101,27 +105,27 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
return store
},
general: {
autoSave: createMemo(() => store.general?.autoSave ?? defaultSettings.general.autoSave),
autoSave: withFallback(() => store.general?.autoSave, defaultSettings.general.autoSave),
setAutoSave(value: boolean) {
setStore("general", "autoSave", value)
},
releaseNotes: createMemo(() => store.general?.releaseNotes ?? defaultSettings.general.releaseNotes),
releaseNotes: withFallback(() => store.general?.releaseNotes, defaultSettings.general.releaseNotes),
setReleaseNotes(value: boolean) {
setStore("general", "releaseNotes", value)
},
},
updates: {
startup: createMemo(() => store.updates?.startup ?? defaultSettings.updates.startup),
startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup),
setStartup(value: boolean) {
setStore("updates", "startup", value)
},
},
appearance: {
fontSize: createMemo(() => store.appearance?.fontSize ?? defaultSettings.appearance.fontSize),
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
setFontSize(value: number) {
setStore("appearance", "fontSize", value)
},
font: createMemo(() => store.appearance?.font ?? defaultSettings.appearance.font),
font: withFallback(() => store.appearance?.font, defaultSettings.appearance.font),
setFont(value: string) {
setStore("appearance", "font", value)
},
@@ -132,42 +136,47 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("keybinds", action, keybind)
},
reset(action: string) {
setStore("keybinds", action, undefined!)
setStore("keybinds", (current) => {
if (!Object.prototype.hasOwnProperty.call(current, action)) return current
const next = { ...current }
delete next[action]
return next
})
},
resetAll() {
setStore("keybinds", reconcile({}))
},
},
permissions: {
autoApprove: createMemo(() => store.permissions?.autoApprove ?? defaultSettings.permissions.autoApprove),
autoApprove: withFallback(() => store.permissions?.autoApprove, defaultSettings.permissions.autoApprove),
setAutoApprove(value: boolean) {
setStore("permissions", "autoApprove", value)
},
},
notifications: {
agent: createMemo(() => store.notifications?.agent ?? defaultSettings.notifications.agent),
agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
setAgent(value: boolean) {
setStore("notifications", "agent", value)
},
permissions: createMemo(() => store.notifications?.permissions ?? defaultSettings.notifications.permissions),
permissions: withFallback(() => store.notifications?.permissions, defaultSettings.notifications.permissions),
setPermissions(value: boolean) {
setStore("notifications", "permissions", value)
},
errors: createMemo(() => store.notifications?.errors ?? defaultSettings.notifications.errors),
errors: withFallback(() => store.notifications?.errors, defaultSettings.notifications.errors),
setErrors(value: boolean) {
setStore("notifications", "errors", value)
},
},
sounds: {
agent: createMemo(() => store.sounds?.agent ?? defaultSettings.sounds.agent),
agent: withFallback(() => store.sounds?.agent, defaultSettings.sounds.agent),
setAgent(value: string) {
setStore("sounds", "agent", value)
},
permissions: createMemo(() => store.sounds?.permissions ?? defaultSettings.sounds.permissions),
permissions: withFallback(() => store.sounds?.permissions, defaultSettings.sounds.permissions),
setPermissions(value: string) {
setStore("sounds", "permissions", value)
},
errors: createMemo(() => store.sounds?.errors ?? defaultSettings.sounds.errors),
errors: withFallback(() => store.sounds?.errors, defaultSettings.sounds.errors),
setErrors(value: string) {
setStore("sounds", "errors", value)
},
+88 -81
View File
@@ -7,6 +7,20 @@ import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
}
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
const pending = map.get(key)
if (pending) return pending
const promise = task().finally(() => {
map.delete(key)
})
map.set(key, promise)
return promise
}
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
@@ -36,7 +50,7 @@ export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddI
const result = Binary.search(messages, input.message.id, (m) => m.id)
messages.splice(result.index, 0, input.message)
}
draft.part[input.message.id] = input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
draft.part[input.message.id] = sortParts(input.parts)
}
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
@@ -48,6 +62,34 @@ export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticR
delete draft.part[input.messageID]
}
function setOptimisticAdd(setStore: (...args: unknown[]) => void, input: OptimisticAddInput) {
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
if (!messages) return [input.message]
const result = Binary.search(messages, input.message.id, (m) => m.id)
const next = [...messages]
next.splice(result.index, 0, input.message)
return next
})
setStore("part", input.message.id, sortParts(input.parts))
}
function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: OptimisticRemoveInput) {
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
if (!messages) return messages
const result = Binary.search(messages, input.messageID, (m) => m.id)
if (!result.found) return messages
const next = [...messages]
next.splice(result.index, 1)
return next
})
setStore("part", (part: Record<string, Part[] | undefined>) => {
if (!(input.messageID in part)) return part
const next = { ...part }
delete next[input.messageID]
return next
})
}
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: () => {
@@ -63,7 +105,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return globalSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const chunk = 400
const messagePageSize = 400
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
@@ -81,8 +123,25 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
const limitFor = (count: number) => {
if (count <= chunk) return chunk
return Math.ceil(count / chunk) * chunk
if (count <= messagePageSize) return messagePageSize
return Math.ceil(count / messagePageSize) * messagePageSize
}
const fetchMessages = async (input: { client: typeof sdk.client; sessionID: string; limit: number }) => {
const messages = await retry(() =>
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }),
)
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const session = items
.map((x) => x.info)
.filter((m) => !!m?.id)
.sort((a, b) => cmp(a.id, b.id))
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
return {
session,
part,
complete: session.length < input.limit,
}
}
const loadMessages = async (input: {
@@ -96,30 +155,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (meta.loading[key]) return
setMeta("loading", key, true)
await retry(() => input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }))
.then((messages) => {
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const next = items
.map((x) => x.info)
.filter((m) => !!m?.id)
.sort((a, b) => cmp(a.id, b.id))
await fetchMessages(input)
.then((next) => {
batch(() => {
input.setStore("message", input.sessionID, reconcile(next, { key: "id" }))
for (const message of items) {
input.setStore(
"part",
message.info.id,
reconcile(
message.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
input.setStore("message", input.sessionID, reconcile(next.session, { key: "id" }))
for (const message of next.part) {
input.setStore("part", message.id, reconcile(message.part, { key: "id" }))
}
setMeta("limit", key, input.limit)
setMeta("complete", key, next.length < input.limit)
setMeta("complete", key, next.complete)
})
})
.finally(() => {
@@ -151,19 +195,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
const [, setStore] = target(input.directory)
setStore(
produce((draft) => {
applyOptimisticAdd(draft as OptimisticStore, input)
}),
)
setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
},
remove(input: { directory?: string; sessionID: string; messageID: string }) {
const [, setStore] = target(input.directory)
setStore(
produce((draft) => {
applyOptimisticRemove(draft as OptimisticStore, input)
}),
)
setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
},
},
addOptimisticMessage(input: {
@@ -182,15 +218,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
model: input.model,
}
const [, setStore] = target()
setStore(
produce((draft) => {
applyOptimisticAdd(draft as OptimisticStore, {
sessionID: input.sessionID,
message,
parts: input.parts,
})
}),
)
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
sessionID: input.sessionID,
message,
parts: input.parts,
})
},
async sync(sessionID: string) {
const directory = sdk.directory
@@ -205,11 +237,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const hasMessages = store.message[sessionID] !== undefined
const hydrated = meta.limit[key] !== undefined
if (hasSession && hasMessages && hydrated) return
const pending = inflight.get(key)
if (pending) return pending
const count = store.message[sessionID]?.length ?? 0
const limit = hydrated ? (meta.limit[key] ?? chunk) : limitFor(count)
const limit = hydrated ? (meta.limit[key] ?? messagePageSize) : limitFor(count)
const sessionReq = hasSession
? Promise.resolve()
@@ -240,14 +270,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
limit,
})
const promise = Promise.all([sessionReq, messagesReq])
.then(() => {})
.finally(() => {
inflight.delete(key)
})
inflight.set(key, promise)
return promise
return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
},
async diff(sessionID: string) {
const directory = sdk.directory
@@ -256,19 +279,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (store.session_diff[sessionID] !== undefined) return
const key = keyFor(directory, sessionID)
const pending = inflightDiff.get(key)
if (pending) return pending
const promise = retry(() => client.session.diff({ sessionID }))
.then((diff) => {
return runInflight(inflightDiff, key, () =>
retry(() => client.session.diff({ sessionID })).then((diff) => {
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
})
.finally(() => {
inflightDiff.delete(key)
})
inflightDiff.set(key, promise)
return promise
}),
)
},
async todo(sessionID: string) {
const directory = sdk.directory
@@ -277,19 +292,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (store.todo[sessionID] !== undefined) return
const key = keyFor(directory, sessionID)
const pending = inflightTodo.get(key)
if (pending) return pending
const promise = retry(() => client.session.todo({ sessionID }))
.then((todo) => {
return runInflight(inflightTodo, key, () =>
retry(() => client.session.todo({ sessionID })).then((todo) => {
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
})
.finally(() => {
inflightTodo.delete(key)
})
inflightTodo.set(key, promise)
return promise
}),
)
},
history: {
more(sessionID: string) {
@@ -304,7 +311,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const key = keyFor(sdk.directory, sessionID)
return meta.loading[key] ?? false
},
async loadMore(sessionID: string, count = chunk) {
async loadMore(sessionID: string, count = messagePageSize) {
const directory = sdk.directory
const client = sdk.client
const [, setStore] = globalSync.child(directory)
@@ -312,7 +319,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (meta.loading[key]) return
if (meta.complete[key]) return
const currentLimit = meta.limit[key] ?? chunk
const currentLimit = meta.limit[key] ?? messagePageSize
await loadMessages({
directory,
client,
+104 -48
View File
@@ -3,7 +3,8 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk"
import { Persist, persisted } from "@/utils/persist"
import type { Platform } from "./platform"
import { Persist, persisted, removePersisted } from "@/utils/persist"
export type LocalPTY = {
id: string
@@ -35,6 +36,28 @@ type TerminalCacheEntry = {
dispose: VoidFunction
}
const caches = new Set<Map<string, TerminalCacheEntry>>()
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
const key = getWorkspaceTerminalCacheKey(dir)
for (const cache of caches) {
const entry = cache.get(key)
entry?.value.clear()
}
removePersisted(Persist.workspace(dir, "terminal"), platform)
const legacy = new Set(getLegacyTerminalStorageKeys(dir))
for (const id of sessionIDs ?? []) {
for (const key of getLegacyTerminalStorageKeys(dir, id)) {
legacy.add(key)
}
}
for (const key of legacy) {
removePersisted({ key }, platform)
}
}
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
@@ -56,19 +79,42 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
}),
)
const unsub = sdk.event.on("pty.exited", (event) => {
const id = event.properties.id
if (!store.all.some((x) => x.id === id)) return
const pickNextTerminalNumber = () => {
const existingTitleNumbers = new Set(
store.all.flatMap((pty) => {
const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
if (direct !== undefined) return [direct]
const parsed = numberFromTitle(pty.title)
if (parsed === undefined) return []
return [parsed]
}),
)
return (
Array.from({ length: existingTitleNumbers.size + 1 }, (_, index) => index + 1).find(
(number) => !existingTitleNumbers.has(number),
) ?? 1
)
}
const removeExited = (id: string) => {
const all = store.all
const index = all.findIndex((x) => x.id === id)
if (index === -1) return
const active = store.active === id ? (index === 0 ? all[1]?.id : all[0]?.id) : store.active
batch(() => {
setStore("active", active)
setStore(
"all",
store.all.filter((x) => x.id !== id),
produce((draft) => {
draft.splice(index, 1)
}),
)
if (store.active === id) {
const remaining = store.all.filter((x) => x.id !== id)
setStore("active", remaining[0]?.id)
}
})
}
const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => {
removeExited(event.properties.id)
})
onCleanup(unsub)
@@ -94,27 +140,20 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
return {
ready,
all: createMemo(() => Object.values(store.all)),
all: createMemo(() => store.all),
active: createMemo(() => store.active),
clear() {
batch(() => {
setStore("active", undefined)
setStore("all", [])
})
},
new() {
const existingTitleNumbers = new Set(
store.all.flatMap((pty) => {
const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
if (direct !== undefined) return [direct]
const parsed = numberFromTitle(pty.title)
if (parsed === undefined) return []
return [parsed]
}),
)
const nextNumber =
Array.from({ length: existingTitleNumbers.size + 1 }, (_, index) => index + 1).find(
(number) => !existingTitleNumbers.has(number),
) ?? 1
const nextNumber = pickNextTerminalNumber()
sdk.client.pty
.create({ title: `Terminal ${nextNumber}` })
.then((pty) => {
.then((pty: { data?: { id?: string; title?: string } }) => {
const id = pty.data?.id
if (!id) return
const newTerminal = {
@@ -122,20 +161,18 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
title: pty.data?.title ?? "Terminal",
titleNumber: nextNumber,
}
setStore("all", (all) => {
const newAll = [...all, newTerminal]
return newAll
})
setStore("all", store.all.length, newTerminal)
setStore("active", id)
})
.catch((e) => {
console.error("Failed to create terminal", e)
.catch((error: unknown) => {
console.error("Failed to create terminal", error)
})
},
update(pty: Partial<LocalPTY> & { id: string }) {
const index = store.all.findIndex((x) => x.id === pty.id)
if (index !== -1) {
setStore("all", index, (existing) => ({ ...existing, ...pty }))
const previous = index >= 0 ? store.all[index] : undefined
if (index >= 0) {
setStore("all", index, (item) => ({ ...item, ...pty }))
}
sdk.client.pty
.update({
@@ -143,8 +180,12 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
.catch((e) => {
console.error("Failed to update terminal", e)
.catch((error: unknown) => {
if (previous) {
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
if (currentIndex >= 0) setStore("all", currentIndex, previous)
}
console.error("Failed to update terminal", error)
})
},
async clone(id: string) {
@@ -155,8 +196,8 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
.create({
title: pty.title,
})
.catch((e) => {
console.error("Failed to clone terminal", e)
.catch((error: unknown) => {
console.error("Failed to clone terminal", error)
return undefined
})
if (!clone?.data) return
@@ -168,6 +209,12 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
id: clone.data.id,
title: clone.data.title ?? pty.title,
titleNumber: pty.titleNumber,
// New PTY process, so start clean.
buffer: undefined,
cursor: undefined,
scrollY: undefined,
rows: undefined,
cols: undefined,
})
if (active) {
setStore("active", clone.data.id)
@@ -190,18 +237,24 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
setStore("active", store.all[prevIndex]?.id)
},
async close(id: string) {
batch(() => {
const filtered = store.all.filter((x) => x.id !== id)
if (store.active === id) {
const index = store.all.findIndex((f) => f.id === id)
const next = index > 0 ? index - 1 : 0
setStore("active", filtered[next]?.id)
}
setStore("all", filtered)
})
const index = store.all.findIndex((f) => f.id === id)
if (index !== -1) {
batch(() => {
if (store.active === id) {
const next = index > 0 ? store.all[index - 1]?.id : store.all[1]?.id
setStore("active", next)
}
setStore(
"all",
produce((all) => {
all.splice(index, 1)
}),
)
})
}
await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
console.error("Failed to close terminal", e)
await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => {
console.error("Failed to close terminal", error)
})
},
move(id: string, to: number) {
@@ -225,6 +278,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
const params = useParams()
const cache = new Map<string, TerminalCacheEntry>()
caches.add(cache)
onCleanup(() => caches.delete(cache))
const disposeAll = () => {
for (const entry of cache.values()) {
entry.dispose()
+105 -85
View File
@@ -8,97 +8,117 @@ import pkg from "../package.json"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
const locale = (() => {
if (typeof navigator !== "object") return "en" as const
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
for (const language of languages) {
if (!language) continue
if (language.toLowerCase().startsWith("zh")) return "zh" as const
}
return "en" as const
})()
const getLocale = () => {
if (typeof navigator !== "object") return "en" as const
const languages = navigator.languages?.length ? navigator.languages : [navigator.language]
for (const language of languages) {
if (!language) continue
if (language.toLowerCase().startsWith("zh")) return "zh" as const
}
return "en" as const
}
const getRootNotFoundError = () => {
const key = "error.dev.rootNotFound" as const
const message = locale === "zh" ? (zh[key] ?? en[key]) : en[key]
throw new Error(message)
const locale = getLocale()
return locale === "zh" ? (zh[key] ?? en[key]) : en[key]
}
const getStorage = (key: string) => {
if (typeof localStorage === "undefined") return null
try {
return localStorage.getItem(key)
} catch {
return null
}
}
const setStorage = (key: string, value: string | null) => {
if (typeof localStorage === "undefined") return
try {
if (value !== null) {
localStorage.setItem(key, value)
return
}
localStorage.removeItem(key)
} catch {
return
}
}
const readDefaultServerUrl = () => getStorage(DEFAULT_SERVER_URL_KEY)
const writeDefaultServerUrl = (url: string | null) => setStorage(DEFAULT_SERVER_URL_KEY, url)
const notify: Platform["notify"] = async (title, description, href) => {
if (!("Notification" in window)) return
const permission =
Notification.permission === "default"
? await Notification.requestPermission().catch(() => "denied")
: Notification.permission
if (permission !== "granted") return
const inView = document.visibilityState === "visible" && document.hasFocus()
if (inView) return
const notification = new Notification(title, {
body: description ?? "",
icon: "https://opencode.ai/favicon-96x96-v3.png",
})
notification.onclick = () => {
window.focus()
if (href) {
window.history.pushState(null, "", href)
window.dispatchEvent(new PopStateEvent("popstate"))
}
notification.close()
}
}
const openLink: Platform["openLink"] = (url) => {
window.open(url, "_blank")
}
const back: Platform["back"] = () => {
window.history.back()
}
const forward: Platform["forward"] = () => {
window.history.forward()
}
const restart: Platform["restart"] = async () => {
window.location.reload()
}
const root = document.getElementById("root")
if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
throw new Error(getRootNotFoundError())
}
const platform: Platform = {
platform: "web",
version: pkg.version,
openLink(url: string) {
window.open(url, "_blank")
},
back() {
window.history.back()
},
forward() {
window.history.forward()
},
restart: async () => {
window.location.reload()
},
notify: async (title, description, href) => {
if (!("Notification" in window)) return
const permission =
Notification.permission === "default"
? await Notification.requestPermission().catch(() => "denied")
: Notification.permission
if (permission !== "granted") return
const inView = document.visibilityState === "visible" && document.hasFocus()
if (inView) return
await Promise.resolve()
.then(() => {
const notification = new Notification(title, {
body: description ?? "",
icon: "https://opencode.ai/favicon-96x96-v3.png",
})
notification.onclick = () => {
window.focus()
if (href) {
window.history.pushState(null, "", href)
window.dispatchEvent(new PopStateEvent("popstate"))
}
notification.close()
}
})
.catch(() => undefined)
},
getDefaultServerUrl: () => {
if (typeof localStorage === "undefined") return null
try {
return localStorage.getItem(DEFAULT_SERVER_URL_KEY)
} catch {
return null
}
},
setDefaultServerUrl: (url) => {
if (typeof localStorage === "undefined") return
try {
if (url) {
localStorage.setItem(DEFAULT_SERVER_URL_KEY, url)
return
}
localStorage.removeItem(DEFAULT_SERVER_URL_KEY)
} catch {
return
}
},
openLink,
back,
forward,
restart,
notify,
getDefaultServerUrl: readDefaultServerUrl,
setDefaultServerUrl: writeDefaultServerUrl,
}
render(
() => (
<PlatformProvider value={platform}>
<AppBaseProviders>
<AppInterface />
</AppBaseProviders>
</PlatformProvider>
),
root!,
)
if (root instanceof HTMLElement) {
render(
() => (
<PlatformProvider value={platform}>
<AppBaseProviders>
<AppInterface />
</AppBaseProviders>
</PlatformProvider>
),
root,
)
}
+10
View File
@@ -1,3 +1,5 @@
import "solid-js"
interface ImportMetaEnv {
readonly VITE_OPENCODE_SERVER_HOST: string
readonly VITE_OPENCODE_SERVER_PORT: string
@@ -6,3 +8,11 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module "solid-js" {
namespace JSX {
interface Directives {
sortable: true
}
}
}
+4 -2
View File
@@ -4,6 +4,7 @@ import { useParams } from "@solidjs/router"
import { createMemo } from "solid-js"
export const popularProviders = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
const popularProviderSet = new Set(popularProviders)
export function useProviders() {
const globalSync = useGlobalSync()
@@ -16,11 +17,12 @@ export function useProviders() {
}
return globalSync.data.provider
})
const connected = createMemo(() => providers().all.filter((p) => providers().connected.includes(p.id)))
const connectedIDs = createMemo(() => new Set(providers().connected))
const connected = createMemo(() => providers().all.filter((p) => connectedIDs().has(p.id)))
const paid = createMemo(() =>
connected().filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)),
)
const popular = createMemo(() => providers().all.filter((p) => popularProviders.includes(p.id)))
const popular = createMemo(() => providers().all.filter((p) => popularProviderSet.has(p.id)))
return {
all: createMemo(() => providers().all),
default: createMemo(() => providers().default),
+79 -79
View File
@@ -16,11 +16,9 @@ export const dict = {
"command.category.permissions": "أذونات",
"command.category.workspace": "مساحة عمل",
"command.category.settings": "إعدادات",
"theme.scheme.system": "نظام",
"theme.scheme.light": "فاتح",
"theme.scheme.dark": "داكن",
"command.sidebar.toggle": "تبديل الشريط الجانبي",
"command.project.open": "فتح مشروع",
"command.provider.connect": "اتصال بموفر",
@@ -31,17 +29,13 @@ export const dict = {
"command.session.previous.unseen": "الجلسة غير المقروءة السابقة",
"command.session.next.unseen": "الجلسة غير المقروءة التالية",
"command.session.archive": "أرشفة الجلسة",
"command.palette": "لوحة الأوامر",
"command.theme.cycle": "تغيير السمة",
"command.theme.set": "استخدام السمة: {{theme}}",
"command.theme.scheme.cycle": "تغيير مخطط الألوان",
"command.theme.scheme.set": "استخدام مخطط الألوان: {{scheme}}",
"command.language.cycle": "تغيير اللغة",
"command.language.set": "استخدام اللغة: {{language}}",
"command.session.new": "جلسة جديدة",
"command.file.open": "فتح ملف",
"command.tab.close": "إغلاق علامة التبويب",
@@ -72,6 +66,7 @@ export const dict = {
"command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا",
"command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا",
"command.workspace.toggle": "تبديل مساحات العمل",
"command.workspace.toggle.description": "تمكين أو تعطيل مساحات العمل المتعددة في الشريط الجانبي",
"command.session.undo": "تراجع",
"command.session.undo.description": "تراجع عن الرسالة الأخيرة",
"command.session.redo": "إعادة",
@@ -84,32 +79,30 @@ export const dict = {
"command.session.share.description": "مشاركة هذه الجلسة ونسخ الرابط إلى الحافظة",
"command.session.unshare": "إلغاء مشاركة الجلسة",
"command.session.unshare.description": "إيقاف مشاركة هذه الجلسة",
"palette.search.placeholder": "البحث في الملفات والأوامر والجلسات",
"palette.empty": "لا توجد نتائج",
"palette.group.commands": "الأوامر",
"palette.group.files": "الملفات",
"dialog.provider.search.placeholder": "البحث عن موفرين",
"dialog.provider.empty": "لم يتم العثور على موفرين",
"dialog.provider.group.popular": "شائع",
"dialog.provider.group.other": "آخر",
"dialog.provider.tag.recommended": "موصى به",
"dialog.provider.opencode.note": "نماذج مختارة تتضمن Claude و GPT و Gemini والمزيد",
"dialog.provider.anthropic.note": "اتصل باستخدام Claude Pro/Max أو مفتاح API",
"dialog.provider.openai.note": "اتصل باستخدام ChatGPT Pro/Plus أو مفتاح API",
"dialog.provider.copilot.note": "اتصل باستخدام Copilot أو مفتاح API",
"dialog.provider.openai.note": "اتصل باستخدام ChatGPT Pro/Plus أو مفتاح API",
"dialog.provider.google.note": "نماذج Gemini لاستجابات سريعة ومنظمة",
"dialog.provider.openrouter.note": "الوصول إلى جميع النماذج المدعومة من موفر واحد",
"dialog.provider.vercel.note": "وصول موحد إلى نماذج الذكاء الاصطناعي مع توجيه ذكي",
"dialog.model.select.title": "تحديد نموذج",
"dialog.model.search.placeholder": "البحث عن نماذج",
"dialog.model.empty": "لا توجد نتائج للنماذج",
"dialog.model.manage": "إدارة النماذج",
"dialog.model.manage.description": "تخصيص النماذج التي تظهر في محدد النماذج.",
"dialog.model.unpaid.freeModels.title": "نماذج مجانية مقدمة من OpenCode",
"dialog.model.unpaid.addMore.title": "إضافة المزيد من النماذج من موفرين مشهورين",
"dialog.provider.viewAll": "عرض المزيد من الموفرين",
"provider.connect.title": "اتصال {{provider}}",
"provider.connect.title.anthropicProMax": "تسجيل الدخول باستخدام Claude Pro/Max",
"provider.connect.selectMethod": "حدد طريقة تسجيل الدخول لـ {{provider}}.",
@@ -144,7 +137,42 @@ export const dict = {
"provider.connect.oauth.auto.confirmationCode": "رمز التأكيد",
"provider.connect.toast.connected.title": "تم توصيل {{provider}}",
"provider.connect.toast.connected.description": "نماذج {{provider}} متاحة الآن للاستخدام.",
"provider.custom.title": "موفر مخصص",
"provider.custom.description.prefix": "تكوين موفر متوافق مع OpenAI. راجع ",
"provider.custom.description.link": "وثائق تكوين الموفر",
"provider.custom.description.suffix": ".",
"provider.custom.field.providerID.label": "معرف الموفر",
"provider.custom.field.providerID.placeholder": "myprovider",
"provider.custom.field.providerID.description": "أحرف صغيرة، أرقام، شرطات، أو شرطات سفلية",
"provider.custom.field.name.label": "اسم العرض",
"provider.custom.field.name.placeholder": "موفر الذكاء الاصطناعي الخاص بي",
"provider.custom.field.baseURL.label": "عنوان URL الأساسي",
"provider.custom.field.baseURL.placeholder": "https://api.myprovider.com/v1",
"provider.custom.field.apiKey.label": "مفتاح API",
"provider.custom.field.apiKey.placeholder": "مفتاح API",
"provider.custom.field.apiKey.description": "اختياري. اتركه فارغًا إذا كنت تدير المصادقة عبر الترويسات.",
"provider.custom.models.label": "النماذج",
"provider.custom.models.id.label": "المعرف",
"provider.custom.models.id.placeholder": "model-id",
"provider.custom.models.name.label": "الاسم",
"provider.custom.models.name.placeholder": "اسم العرض",
"provider.custom.models.remove": "إزالة النموذج",
"provider.custom.models.add": "إضافة نموذج",
"provider.custom.headers.label": "الترويسات (اختياري)",
"provider.custom.headers.key.label": "ترويسة",
"provider.custom.headers.key.placeholder": "Header-Name",
"provider.custom.headers.value.label": "القيمة",
"provider.custom.headers.value.placeholder": "قيمة",
"provider.custom.headers.remove": "إزالة الترويسة",
"provider.custom.headers.add": "إضافة ترويسة",
"provider.custom.error.providerID.required": "معرف الموفر مطلوب",
"provider.custom.error.providerID.format": "استخدم أحرفًا صغيرة، أرقامًا، شرطات، أو شرطات سفلية",
"provider.custom.error.providerID.exists": "معرف الموفر هذا موجود بالفعل",
"provider.custom.error.name.required": "اسم العرض مطلوب",
"provider.custom.error.baseURL.required": "عنوان URL الأساسي مطلوب",
"provider.custom.error.baseURL.format": "يجب أن يبدأ بـ http:// أو https://",
"provider.custom.error.required": "مطلوب",
"provider.custom.error.duplicate": "مكرر",
"provider.disconnect.toast.disconnected.title": "تم فصل {{provider}}",
"provider.disconnect.toast.disconnected.description": "لم تعد نماذج {{provider}} متاحة.",
"model.tag.free": "مجاني",
@@ -163,9 +191,9 @@ export const dict = {
"model.tooltip.reasoning.allowed": "يسمح بالاستنتاج",
"model.tooltip.reasoning.none": "بدون استنتاج",
"model.tooltip.context": "حد السياق {{limit}}",
"common.search.placeholder": "بحث",
"common.goBack": "رجوع",
"common.goForward": "انتقل للأمام",
"common.loading": "جارٍ التحميل",
"common.loading.ellipsis": "...",
"common.cancel": "إلغاء",
@@ -176,14 +204,12 @@ export const dict = {
"common.saving": "جارٍ الحفظ...",
"common.default": "افتراضي",
"common.attachment": "مرفق",
"prompt.placeholder.shell": "أدخل أمر shell...",
"prompt.placeholder.normal": 'اسأل أي شيء... "{{example}}"',
"prompt.placeholder.summarizeComments": "لخّص التعليقات…",
"prompt.placeholder.summarizeComment": "لخّص التعليق…",
"prompt.mode.shell": "Shell",
"prompt.mode.shell.exit": "esc للخروج",
"prompt.example.1": "إصلاح TODO في قاعدة التعليمات البرمجية",
"prompt.example.2": "ما هو المكدس التقني لهذا المشروع؟",
"prompt.example.3": "إصلاح الاختبارات المعطلة",
@@ -209,7 +235,6 @@ export const dict = {
"prompt.example.23": "إضافة ترقيم الصفحات إلى هذه القائمة",
"prompt.example.24": "إنشاء أمر CLI لـ...",
"prompt.example.25": "كيف تعمل متغيرات البيئة هنا؟",
"prompt.popover.emptyResults": "لا توجد نتائج مطابقة",
"prompt.popover.emptyCommands": "لا توجد أوامر مطابقة",
"prompt.dropzone.label": "أفلت الصور أو ملفات PDF هنا",
@@ -225,7 +250,6 @@ export const dict = {
"prompt.attachment.remove": "إزالة المرفق",
"prompt.action.send": "إرسال",
"prompt.action.stop": "توقف",
"prompt.toast.pasteUnsupported.title": "لصق غير مدعوم",
"prompt.toast.pasteUnsupported.description": "يمكن لصق الصور أو ملفات PDF فقط هنا.",
"prompt.toast.modelAgentRequired.title": "حدد وكيلاً ونموذجاً",
@@ -236,24 +260,18 @@ export const dict = {
"prompt.toast.commandSendFailed.title": "فشل إرسال الأمر",
"prompt.toast.promptSendFailed.title": "فشل إرسال الموجه",
"prompt.toast.promptSendFailed.description": "تعذر استرداد الجلسة",
"dialog.mcp.title": "MCPs",
"dialog.mcp.description": "{{enabled}} من {{total}} مفعل",
"dialog.mcp.empty": "لم يتم تكوين MCPs",
"dialog.lsp.empty": "تم الكشف تلقائيًا عن LSPs من أنواع الملفات",
"dialog.plugins.empty": "الإضافات المكونة في opencode.json",
"mcp.status.connected": "متصل",
"mcp.status.failed": "فشل",
"mcp.status.needs_auth": "يحتاج إلى مصادقة",
"mcp.status.disabled": "معطل",
"dialog.fork.empty": "لا توجد رسائل للتفرع منها",
"dialog.directory.search.placeholder": "البحث في المجلدات",
"dialog.directory.empty": "لم يتم العثور على مجلدات",
"dialog.server.title": "الخوادم",
"dialog.server.description": "تبديل خادم OpenCode الذي يتصل به هذا التطبيق.",
"dialog.server.search.placeholder": "البحث في الخوادم",
@@ -271,14 +289,12 @@ export const dict = {
"dialog.server.default.set": "تعيين الخادم الحالي كافتراضي",
"dialog.server.default.clear": "مسح",
"dialog.server.action.remove": "إزالة الخادم",
"dialog.server.menu.edit": "تعديل",
"dialog.server.menu.default": "تعيين كافتراضي",
"dialog.server.menu.defaultRemove": "إزالة الافتراضي",
"dialog.server.menu.delete": "حذف",
"dialog.server.current": "الخادم الحالي",
"dialog.server.status.default": "افتراضي",
"dialog.project.edit.title": "تحرير المشروع",
"dialog.project.edit.name": "الاسم",
"dialog.project.edit.icon": "أيقونة",
@@ -287,7 +303,6 @@ export const dict = {
"dialog.project.edit.icon.recommended": "موصى به: 128x128px",
"dialog.project.edit.color": "لون",
"dialog.project.edit.color.select": "اختر لون {{color}}",
"dialog.project.edit.worktree.startup": "سكريبت بدء تشغيل مساحة العمل",
"dialog.project.edit.worktree.startup.description": "يتم تشغيله بعد إنشاء مساحة عمل جديدة (شجرة عمل).",
"dialog.project.edit.worktree.startup.placeholder": "مثال: bun install",
@@ -298,10 +313,8 @@ export const dict = {
"context.breakdown.assistant": "المساعد",
"context.breakdown.tool": "استدعاءات الأداة",
"context.breakdown.other": "أخرى",
"context.systemPrompt.title": "موجه النظام",
"context.rawMessages.title": "الرسائل الخام",
"context.stats.session": "جلسة",
"context.stats.messages": "رسائل",
"context.stats.provider": "موفر",
@@ -318,34 +331,42 @@ export const dict = {
"context.stats.totalCost": "التكلفة الإجمالية",
"context.stats.sessionCreated": "تم إنشاء الجلسة",
"context.stats.lastActivity": "آخر نشاط",
"context.usage.tokens": "رموز",
"context.usage.usage": "استخدام",
"context.usage.cost": "تكلفة",
"context.usage.clickToView": "انقر لعرض السياق",
"context.usage.view": "عرض استخدام السياق",
"language.en": "English",
"language.zh": "简体中文",
"language.zht": "繁體中文",
"language.ko": "한국어",
"language.de": "Deutsch",
"language.es": "Español",
"language.fr": "Français",
"language.da": "Dansk",
"language.ja": "日本語",
"language.pl": "Polski",
"language.ru": "Русский",
"language.ar": "العربية",
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.th": "ไทย",
"toast.language.title": "لغة",
"toast.language.description": "تم التبديل إلى {{language}}",
"toast.theme.title": "تم تبديل السمة",
"toast.scheme.title": "مخطط الألوان",
"toast.permissions.autoaccept.on.title": "قبول التعديلات تلقائيًا",
"toast.permissions.autoaccept.on.description": "سيتم الموافقة تلقائيًا على أذونات التحرير والكتابة",
"toast.permissions.autoaccept.off.title": "توقف قبول التعديلات تلقائيًا",
"toast.permissions.autoaccept.off.description": "ستتطلب أذونات التحرير والكتابة موافقة",
"toast.workspace.enabled.title": "تم تمكين مساحات العمل",
"toast.workspace.enabled.description": "الآن يتم عرض عدة worktrees في الشريط الجانبي",
"toast.workspace.disabled.title": "تم تعطيل مساحات العمل",
"toast.workspace.disabled.description": "يتم عرض worktree الرئيسي فقط في الشريط الجانبي",
"toast.permissions.autoaccept.on.title": "قبول التعديلات تلقائيًا",
"toast.permissions.autoaccept.on.description": "سيتم الموافقة تلقائيًا على أذونات التحرير والكتابة",
"toast.permissions.autoaccept.off.title": "توقف قبول التعديلات تلقائيًا",
"toast.permissions.autoaccept.off.description": "ستتطلب أذونات التحرير والكتابة موافقة",
"toast.model.none.title": "لم يتم تحديد نموذج",
"toast.model.none.description": "قم بتوصيل موفر لتلخيص هذه الجلسة",
"toast.file.loadFailed.title": "فشل تحميل الملف",
"toast.file.listFailed.title": "فشل سرد الملفات",
"toast.context.noLineSelection.title": "لا يوجد تحديد للأسطر",
"toast.context.noLineSelection.description": "حدد نطاق أسطر في تبويب ملف أولاً.",
@@ -354,19 +375,15 @@ export const dict = {
"toast.session.share.success.description": "تم نسخ عنوان URL للمشاركة إلى الحافظة!",
"toast.session.share.failed.title": "فشل مشاركة الجلسة",
"toast.session.share.failed.description": "حدث خطأ أثناء مشاركة الجلسة",
"toast.session.unshare.success.title": "تم إلغاء مشاركة الجلسة",
"toast.session.unshare.success.description": "تم إلغاء مشاركة الجلسة بنجاح!",
"toast.session.unshare.failed.title": "فشل إلغاء مشاركة الجلسة",
"toast.session.unshare.failed.description": "حدث خطأ أثناء إلغاء مشاركة الجلسة",
"toast.session.listFailed.title": "فشل تحميل الجلسات لـ {{project}}",
"toast.update.title": "تحديث متاح",
"toast.update.description": "نسخة جديدة من OpenCode ({{version}}) متاحة الآن للتثبيت.",
"toast.update.action.installRestart": "تثبيت وإعادة تشغيل",
"toast.update.action.notYet": "ليس الآن",
"error.page.title": "حدث خطأ ما",
"error.page.description": "حدث خطأ أثناء تحميل التطبيق.",
"error.page.details.label": "تفاصيل الخطأ",
@@ -377,12 +394,10 @@ export const dict = {
"error.page.report.prefix": "يرجى الإبلاغ عن هذا الخطأ لفريق OpenCode",
"error.page.report.discord": "على Discord",
"error.page.version": "الإصدار: {{version}}",
"error.dev.rootNotFound":
"لم يتم العثور على العنصر الجذري. هل نسيت إضافته إلى index.html؟ أو ربما تمت كتابة سمة id بشكل خاطئ؟",
"error.globalSync.connectFailed": "تعذر الاتصال بالخادم. هل هناك خادم يعمل في `{{url}}`؟",
"directory.error.invalidUrl": "دليل غير صالح في عنوان URL.",
"error.chain.unknown": "خطأ غير معروف",
"error.chain.causedBy": "بسبب:",
"error.chain.apiError": "خطأ API",
@@ -402,21 +417,17 @@ export const dict = {
"error.chain.configFrontmatterError": "فشل تحليل frontmatter في {{path}}:\n{{message}}",
"error.chain.configInvalid": "ملف التكوين في {{path}} غير صالح",
"error.chain.configInvalidWithMessage": "ملف التكوين في {{path}} غير صالح: {{message}}",
"notification.permission.title": "مطلوب إذن",
"notification.permission.description": "{{sessionTitle}} في {{projectName}} يحتاج إلى إذن",
"notification.question.title": "سؤال",
"notification.question.description": "{{sessionTitle}} في {{projectName}} لديه سؤال",
"notification.action.goToSession": "انتقل إلى الجلسة",
"notification.session.responseReady.title": "الاستجابة جاهزة",
"notification.session.error.title": "خطأ في الجلسة",
"notification.session.error.fallbackDescription": "حدث خطأ",
"home.recentProjects": "المشاريع الحديثة",
"home.empty.title": "لا توجد مشاريع حديثة",
"home.empty.description": "ابدأ بفتح مشروع محلي",
"session.tab.session": "جلسة",
"session.tab.review": "مراجعة",
"session.tab.context": "سياق",
@@ -435,17 +446,18 @@ export const dict = {
"session.messages.loadEarlier": "تحميل الرسائل السابقة",
"session.messages.loading": "جارٍ تحميل الرسائل...",
"session.messages.jumpToLatest": "الانتقال إلى الأحدث",
"session.context.addToContext": "إضافة {{selection}} إلى السياق",
"session.new.worktree.main": "الفرع الرئيسي",
"session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})",
"session.new.worktree.create": "إنشاء شجرة عمل جديدة",
"session.new.lastModified": "آخر تعديل",
"session.header.search.placeholder": "بحث {{project}}",
"session.header.searchFiles": "بحث عن الملفات",
"session.header.openIn": "فتح في",
"session.header.open.action": "فتح {{app}}",
"session.header.open.ariaLabel": "فتح في {{app}}",
"session.header.open.menu": "خيارات الفتح",
"session.header.open.copyPath": "نسخ المسار",
"status.popover.trigger": "الحالة",
"status.popover.ariaLabel": "إعدادات الخوادم",
"status.popover.tab.servers": "الخوادم",
@@ -453,7 +465,6 @@ export const dict = {
"status.popover.tab.lsp": "LSP",
"status.popover.tab.plugins": "الإضافات",
"status.popover.action.manageServers": "إدارة الخوادم",
"session.share.popover.title": "نشر على الويب",
"session.share.popover.description.shared": "هذه الجلسة عامة على الويب. يمكن لأي شخص لديه الرابط الوصول إليها.",
"session.share.popover.description.unshared": "شارك الجلسة علنًا على الويب. ستكون متاحة لأي شخص لديه الرابط.",
@@ -465,10 +476,8 @@ export const dict = {
"session.share.action.view": "عرض",
"session.share.copy.copied": "تم النسخ",
"session.share.copy.copyLink": "نسخ الرابط",
"lsp.tooltip.none": "لا توجد خوادم LSP",
"lsp.label.connected": "{{count}} LSP",
"prompt.loading": "جارٍ تحميل الموجه...",
"terminal.loading": "جارٍ تحميل المحطة الطرفية...",
"terminal.title": "محطة طرفية",
@@ -476,7 +485,6 @@ export const dict = {
"terminal.close": "إغلاق المحطة الطرفية",
"terminal.connectionLost.title": "فقد الاتصال",
"terminal.connectionLost.description": "انقطع اتصال المحطة الطرفية. يمكن أن يحدث هذا عند إعادة تشغيل الخادم.",
"common.closeTab": "إغلاق علامة التبويب",
"common.dismiss": "رفض",
"common.requestFailed": "فشل الطلب",
@@ -490,7 +498,6 @@ export const dict = {
"common.edit": "تحرير",
"common.loadMore": "تحميل المزيد",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "تبديل القائمة",
"sidebar.nav.projectsAndSessions": "المشاريع والجلسات",
"sidebar.settings": "الإعدادات",
@@ -502,18 +509,19 @@ export const dict = {
"sidebar.gettingStarted.line2": "قم بتوصيل أي موفر لاستخدام النماذج، بما في ذلك Claude و GPT و Gemini وما إلى ذلك.",
"sidebar.project.recentSessions": "الجلسات الحديثة",
"sidebar.project.viewAllSessions": "عرض جميع الجلسات",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "سطح المكتب",
"settings.section.server": "الخادم",
"settings.tab.general": "عام",
"settings.tab.shortcuts": "اختصارات",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "تكامل WSL",
"settings.desktop.wsl.description": "تشغيل خادم OpenCode داخل WSL على Windows.",
"settings.general.section.appearance": "المظهر",
"settings.general.section.notifications": "إشعارات النظام",
"settings.general.section.updates": "التحديثات",
"settings.general.section.sounds": "المؤثرات الصوتية",
"settings.general.section.display": "شاشة العرض",
"settings.general.row.language.title": "اللغة",
"settings.general.row.language.description": "تغيير لغة العرض لـ OpenCode",
"settings.general.row.appearance.title": "المظهر",
@@ -522,10 +530,12 @@ export const dict = {
"settings.general.row.theme.description": "تخصيص سمة OpenCode.",
"settings.general.row.font.title": "الخط",
"settings.general.row.font.description": "تخصيص الخط الأحادي المستخدم في كتل التعليمات البرمجية",
"settings.general.row.wayland.title": "استخدام Wayland الأصلي",
"settings.general.row.wayland.description": "تعطيل التراجع إلى X11 على Wayland. يتطلب إعادة التشغيل.",
"settings.general.row.wayland.tooltip":
"على Linux مع شاشات بمعدلات تحديث مختلطة، يمكن أن يكون Wayland الأصلي أكثر استقرارًا.",
"settings.general.row.releaseNotes.title": "ملاحظات الإصدار",
"settings.general.row.releaseNotes.description": 'عرض نوافذ "ما الجديد" المنبثقة بعد التحديثات',
"settings.updates.row.startup.title": "التحقق من التحديثات عند بدء التشغيل",
"settings.updates.row.startup.description": "التحقق تلقائيًا من التحديثات عند تشغيل OpenCode",
"settings.updates.row.check.title": "التحقق من التحديثات",
@@ -591,21 +601,18 @@ export const dict = {
"sound.option.yup04": "نعم 04",
"sound.option.yup05": "نعم 05",
"sound.option.yup06": "نعم 06",
"settings.general.notifications.agent.title": "وكيل",
"settings.general.notifications.agent.description": "عرض إشعار النظام عندما يكتمل الوكيل أو يحتاج إلى اهتمام",
"settings.general.notifications.permissions.title": "أذونات",
"settings.general.notifications.permissions.description": "عرض إشعار النظام عند الحاجة إلى إذن",
"settings.general.notifications.errors.title": "أخطاء",
"settings.general.notifications.errors.description": "عرض إشعار النظام عند حدوث خطأ",
"settings.general.sounds.agent.title": "وكيل",
"settings.general.sounds.agent.description": "تشغيل صوت عندما يكتمل الوكيل أو يحتاج إلى اهتمام",
"settings.general.sounds.permissions.title": "أذونات",
"settings.general.sounds.permissions.description": "تشغيل صوت عند الحاجة إلى إذن",
"settings.general.sounds.errors.title": "أخطاء",
"settings.general.sounds.errors.description": "تشغيل صوت عند حدوث خطأ",
"settings.shortcuts.title": "اختصارات لوحة المفاتيح",
"settings.shortcuts.reset.button": "إعادة التعيين إلى الافتراضيات",
"settings.shortcuts.reset.toast.title": "تم إعادة تعيين الاختصارات",
@@ -616,14 +623,12 @@ export const dict = {
"settings.shortcuts.pressKeys": "اضغط على المفاتيح",
"settings.shortcuts.search.placeholder": "البحث في الاختصارات",
"settings.shortcuts.search.empty": "لم يتم العثور على اختصارات",
"settings.shortcuts.group.general": "عام",
"settings.shortcuts.group.session": "جلسة",
"settings.shortcuts.group.navigation": "تصفح",
"settings.shortcuts.group.modelAndAgent": "النموذج والوكيل",
"settings.shortcuts.group.terminal": "المحطة الطرفية",
"settings.shortcuts.group.prompt": "موجه",
"settings.providers.title": "الموفرون",
"settings.providers.description": "ستكون إعدادات الموفر قابلة للتكوين هنا.",
"settings.providers.section.connected": "الموفرون المتصلون",
@@ -641,16 +646,13 @@ export const dict = {
"settings.commands.description": "ستكون إعدادات الأمر قابلة للتكوين هنا.",
"settings.mcp.title": "MCP",
"settings.mcp.description": "ستكون إعدادات MCP قابلة للتكوين هنا.",
"settings.permissions.title": "الأذونات",
"settings.permissions.description": "تحكم في الأدوات التي يمكن للخادم استخدامها بشكل افتراضي.",
"settings.permissions.section.tools": "الأدوات",
"settings.permissions.toast.updateFailed.title": "فشل تحديث الأذونات",
"settings.permissions.action.allow": "سماح",
"settings.permissions.action.ask": "سؤال",
"settings.permissions.action.deny": "رفض",
"settings.permissions.tool.read.title": "قراءة",
"settings.permissions.tool.read.description": "قراءة ملف (يطابق مسار الملف)",
"settings.permissions.tool.edit.title": "تحرير",
@@ -664,9 +666,9 @@ export const dict = {
"settings.permissions.tool.list.description": "سرد الملفات داخل دليل",
"settings.permissions.tool.bash.title": "Bash",
"settings.permissions.tool.bash.description": "تشغيل أوامر shell",
"settings.permissions.tool.task.title": "Task",
"settings.permissions.tool.task.title": "مهمة",
"settings.permissions.tool.task.description": "تشغيل الوكلاء الفرعيين",
"settings.permissions.tool.skill.title": "Skill",
"settings.permissions.tool.skill.title": "مهارة",
"settings.permissions.tool.skill.description": "تحميل مهارة بالاسم",
"settings.permissions.tool.lsp.title": "LSP",
"settings.permissions.tool.lsp.description": "تشغيل استعلامات خادم اللغة",
@@ -684,12 +686,10 @@ export const dict = {
"settings.permissions.tool.external_directory.description": "الوصول إلى الملفات خارج دليل المشروع",
"settings.permissions.tool.doom_loop.title": "حلقة الموت",
"settings.permissions.tool.doom_loop.description": "اكتشاف استدعاءات الأدوات المتكررة بمدخلات متطابقة",
"session.delete.failed.title": "فشل حذف الجلسة",
"session.delete.title": "حذف الجلسة",
"session.delete.confirm": 'حذف الجلسة "{{name}}"؟',
"session.delete.button": "حذف الجلسة",
"workspace.new": "مساحة عمل جديدة",
"workspace.type.local": "محلي",
"workspace.type.sandbox": "صندوق رمل",
+78 -76
View File
@@ -16,11 +16,9 @@ export const dict = {
"command.category.permissions": "Permissões",
"command.category.workspace": "Espaço de trabalho",
"command.category.settings": "Configurações",
"theme.scheme.system": "Sistema",
"theme.scheme.light": "Claro",
"theme.scheme.dark": "Escuro",
"command.sidebar.toggle": "Alternar barra lateral",
"command.project.open": "Abrir projeto",
"command.provider.connect": "Conectar provedor",
@@ -31,17 +29,13 @@ export const dict = {
"command.session.previous.unseen": "Sessão não lida anterior",
"command.session.next.unseen": "Próxima sessão não lida",
"command.session.archive": "Arquivar sessão",
"command.palette": "Paleta de comandos",
"command.theme.cycle": "Alternar tema",
"command.theme.set": "Usar tema: {{theme}}",
"command.theme.scheme.cycle": "Alternar esquema de cores",
"command.theme.scheme.set": "Usar esquema de cores: {{scheme}}",
"command.language.cycle": "Alternar idioma",
"command.language.set": "Usar idioma: {{language}}",
"command.session.new": "Nova sessão",
"command.file.open": "Abrir arquivo",
"command.tab.close": "Fechar aba",
@@ -72,6 +66,7 @@ export const dict = {
"command.permissions.autoaccept.enable": "Aceitar edições automaticamente",
"command.permissions.autoaccept.disable": "Parar de aceitar edições automaticamente",
"command.workspace.toggle": "Alternar espaços de trabalho",
"command.workspace.toggle.description": "Habilitar ou desabilitar múltiplos espaços de trabalho na barra lateral",
"command.session.undo": "Desfazer",
"command.session.undo.description": "Desfazer a última mensagem",
"command.session.redo": "Refazer",
@@ -84,32 +79,30 @@ export const dict = {
"command.session.share.description": "Compartilhar esta sessão e copiar a URL para a área de transferência",
"command.session.unshare": "Parar de compartilhar sessão",
"command.session.unshare.description": "Parar de compartilhar esta sessão",
"palette.search.placeholder": "Buscar arquivos, comandos e sessões",
"palette.empty": "Nenhum resultado encontrado",
"palette.group.commands": "Comandos",
"palette.group.files": "Arquivos",
"dialog.provider.search.placeholder": "Buscar provedores",
"dialog.provider.empty": "Nenhum provedor encontrado",
"dialog.provider.group.popular": "Popular",
"dialog.provider.group.other": "Outro",
"dialog.provider.tag.recommended": "Recomendado",
"dialog.provider.opencode.note": "Modelos selecionados incluindo Claude, GPT, Gemini e mais",
"dialog.provider.anthropic.note": "Conectar com Claude Pro/Max ou chave de API",
"dialog.provider.openai.note": "Conectar com ChatGPT Pro/Plus ou chave de API",
"dialog.provider.copilot.note": "Conectar com Copilot ou chave de API",
"dialog.provider.openai.note": "Conectar com ChatGPT Pro/Plus ou chave de API",
"dialog.provider.google.note": "Modelos Gemini para respostas rápidas e estruturadas",
"dialog.provider.openrouter.note": "Acesse todos os modelos suportados de um único provedor",
"dialog.provider.vercel.note": "Acesso unificado a modelos de IA com roteamento inteligente",
"dialog.model.select.title": "Selecionar modelo",
"dialog.model.search.placeholder": "Buscar modelos",
"dialog.model.empty": "Nenhum resultado de modelo",
"dialog.model.manage": "Gerenciar modelos",
"dialog.model.manage.description": "Personalizar quais modelos aparecem no seletor de modelos.",
"dialog.model.unpaid.freeModels.title": "Modelos gratuitos fornecidos pelo OpenCode",
"dialog.model.unpaid.addMore.title": "Adicionar mais modelos de provedores populares",
"dialog.provider.viewAll": "Ver mais provedores",
"provider.connect.title": "Conectar {{provider}}",
"provider.connect.title.anthropicProMax": "Entrar com Claude Pro/Max",
"provider.connect.selectMethod": "Selecionar método de login para {{provider}}.",
@@ -144,7 +137,42 @@ export const dict = {
"provider.connect.oauth.auto.confirmationCode": "Código de confirmação",
"provider.connect.toast.connected.title": "{{provider}} conectado",
"provider.connect.toast.connected.description": "Modelos do {{provider}} agora estão disponíveis para uso.",
"provider.custom.title": "Provedor personalizado",
"provider.custom.description.prefix": "Configure um provedor compatível com OpenAI. Veja a ",
"provider.custom.description.link": "documentação de configuração do provedor",
"provider.custom.description.suffix": ".",
"provider.custom.field.providerID.label": "ID do Provedor",
"provider.custom.field.providerID.placeholder": "meuprovedor",
"provider.custom.field.providerID.description": "Letras minúsculas, números, hifens ou sublinhados",
"provider.custom.field.name.label": "Nome de exibição",
"provider.custom.field.name.placeholder": "Meu Provedor de IA",
"provider.custom.field.baseURL.label": "URL Base",
"provider.custom.field.baseURL.placeholder": "https://api.meuprovedor.com/v1",
"provider.custom.field.apiKey.label": "Chave de API",
"provider.custom.field.apiKey.placeholder": "Chave de API",
"provider.custom.field.apiKey.description": "Opcional. Deixe em branco se gerenciar autenticação via cabeçalhos.",
"provider.custom.models.label": "Modelos",
"provider.custom.models.id.label": "ID",
"provider.custom.models.id.placeholder": "id-do-modelo",
"provider.custom.models.name.label": "Nome",
"provider.custom.models.name.placeholder": "Nome de Exibição",
"provider.custom.models.remove": "Remover modelo",
"provider.custom.models.add": "Adicionar modelo",
"provider.custom.headers.label": "Cabeçalhos (opcional)",
"provider.custom.headers.key.label": "Cabeçalho",
"provider.custom.headers.key.placeholder": "Nome-Do-Cabeçalho",
"provider.custom.headers.value.label": "Valor",
"provider.custom.headers.value.placeholder": "valor",
"provider.custom.headers.remove": "Remover cabeçalho",
"provider.custom.headers.add": "Adicionar cabeçalho",
"provider.custom.error.providerID.required": "ID do Provedor é obrigatório",
"provider.custom.error.providerID.format": "Use letras minúsculas, números, hifens ou sublinhados",
"provider.custom.error.providerID.exists": "Esse ID de provedor já existe",
"provider.custom.error.name.required": "Nome de exibição é obrigatório",
"provider.custom.error.baseURL.required": "URL Base é obrigatória",
"provider.custom.error.baseURL.format": "Deve começar com http:// ou https://",
"provider.custom.error.required": "Obrigatório",
"provider.custom.error.duplicate": "Duplicado",
"provider.disconnect.toast.disconnected.title": "{{provider}} desconectado",
"provider.disconnect.toast.disconnected.description": "Os modelos de {{provider}} não estão mais disponíveis.",
"model.tag.free": "Grátis",
@@ -163,9 +191,9 @@ export const dict = {
"model.tooltip.reasoning.allowed": "Permite raciocínio",
"model.tooltip.reasoning.none": "Sem raciocínio",
"model.tooltip.context": "Limite de contexto {{limit}}",
"common.search.placeholder": "Buscar",
"common.goBack": "Voltar",
"common.goForward": "Avançar",
"common.loading": "Carregando",
"common.loading.ellipsis": "...",
"common.cancel": "Cancelar",
@@ -176,14 +204,12 @@ export const dict = {
"common.saving": "Salvando...",
"common.default": "Padrão",
"common.attachment": "anexo",
"prompt.placeholder.shell": "Digite comando do shell...",
"prompt.placeholder.normal": 'Pergunte qualquer coisa... "{{example}}"',
"prompt.placeholder.summarizeComments": "Resumir comentários…",
"prompt.placeholder.summarizeComment": "Resumir comentário…",
"prompt.mode.shell": "Shell",
"prompt.mode.shell.exit": "esc para sair",
"prompt.example.1": "Corrigir um TODO no código",
"prompt.example.2": "Qual é a stack tecnológica deste projeto?",
"prompt.example.3": "Corrigir testes quebrados",
@@ -209,7 +235,6 @@ export const dict = {
"prompt.example.23": "Adicionar paginação a esta lista",
"prompt.example.24": "Criar um comando CLI para...",
"prompt.example.25": "Como funcionam as variáveis de ambiente aqui?",
"prompt.popover.emptyResults": "Nenhum resultado correspondente",
"prompt.popover.emptyCommands": "Nenhum comando correspondente",
"prompt.dropzone.label": "Solte imagens ou PDFs aqui",
@@ -225,7 +250,6 @@ export const dict = {
"prompt.attachment.remove": "Remover anexo",
"prompt.action.send": "Enviar",
"prompt.action.stop": "Parar",
"prompt.toast.pasteUnsupported.title": "Colagem não suportada",
"prompt.toast.pasteUnsupported.description": "Somente imagens ou PDFs podem ser colados aqui.",
"prompt.toast.modelAgentRequired.title": "Selecione um agente e modelo",
@@ -236,23 +260,18 @@ export const dict = {
"prompt.toast.commandSendFailed.title": "Falha ao enviar comando",
"prompt.toast.promptSendFailed.title": "Falha ao enviar prompt",
"prompt.toast.promptSendFailed.description": "Não foi possível recuperar a sessão",
"dialog.mcp.title": "MCPs",
"dialog.mcp.description": "{{enabled}} de {{total}} habilitados",
"dialog.mcp.description": "{{enabled}} of {{total}} habilitados",
"dialog.mcp.empty": "Nenhum MCP configurado",
"dialog.lsp.empty": "LSPs detectados automaticamente pelos tipos de arquivo",
"dialog.plugins.empty": "Plugins configurados em opencode.json",
"mcp.status.connected": "conectado",
"mcp.status.failed": "falhou",
"mcp.status.needs_auth": "precisa de autenticação",
"mcp.status.disabled": "desabilitado",
"dialog.fork.empty": "Nenhuma mensagem para bifurcar",
"dialog.directory.search.placeholder": "Buscar pastas",
"dialog.directory.empty": "Nenhuma pasta encontrada",
"dialog.server.title": "Servidores",
"dialog.server.description": "Trocar para qual servidor OpenCode este aplicativo se conecta.",
"dialog.server.search.placeholder": "Buscar servidores",
@@ -270,7 +289,6 @@ export const dict = {
"dialog.server.default.set": "Definir servidor atual como padrão",
"dialog.server.default.clear": "Limpar",
"dialog.server.action.remove": "Remover servidor",
"dialog.server.menu.edit": "Editar",
"dialog.server.menu.default": "Definir como padrão",
"dialog.server.menu.defaultRemove": "Remover padrão",
@@ -288,7 +306,6 @@ export const dict = {
"dialog.project.edit.worktree.startup": "Script de inicialização do espaço de trabalho",
"dialog.project.edit.worktree.startup.description": "Executa após criar um novo espaço de trabalho (worktree).",
"dialog.project.edit.worktree.startup.placeholder": "ex: bun install",
"context.breakdown.title": "Detalhamento do Contexto",
"context.breakdown.note":
'Detalhamento aproximado dos tokens de entrada. "Outros" inclui definições de ferramentas e overhead.',
@@ -297,10 +314,8 @@ export const dict = {
"context.breakdown.assistant": "Assistente",
"context.breakdown.tool": "Chamadas de Ferramentas",
"context.breakdown.other": "Outros",
"context.systemPrompt.title": "Prompt do Sistema",
"context.rawMessages.title": "Mensagens brutas",
"context.stats.session": "Sessão",
"context.stats.messages": "Mensagens",
"context.stats.provider": "Provedor",
@@ -317,34 +332,42 @@ export const dict = {
"context.stats.totalCost": "Custo Total",
"context.stats.sessionCreated": "Sessão Criada",
"context.stats.lastActivity": "Última Atividade",
"context.usage.tokens": "Tokens",
"context.usage.usage": "Uso",
"context.usage.cost": "Custo",
"context.usage.clickToView": "Clique para ver o contexto",
"context.usage.view": "Ver uso do contexto",
"language.en": "English",
"language.zh": "简体中文",
"language.zht": "繁體中文",
"language.ko": "한국어",
"language.de": "Deutsch",
"language.es": "Español",
"language.fr": "Français",
"language.da": "Dansk",
"language.ja": "日本語",
"language.pl": "Polski",
"language.ru": "Русский",
"language.ar": "العربية",
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"language.bs": "Bosanski",
"language.th": "ไทย",
"toast.language.title": "Idioma",
"toast.language.description": "Alterado para {{language}}",
"toast.theme.title": "Tema alterado",
"toast.scheme.title": "Esquema de cores",
"toast.permissions.autoaccept.on.title": "Aceitando edições automaticamente",
"toast.permissions.autoaccept.on.description": "Permissões de edição e escrita serão aprovadas automaticamente",
"toast.permissions.autoaccept.off.title": "Parou de aceitar edições automaticamente",
"toast.permissions.autoaccept.off.description": "Permissões de edição e escrita exigirão aprovação",
"toast.workspace.enabled.title": "Espaços de trabalho ativados",
"toast.workspace.enabled.description": "Várias worktrees agora são exibidas na barra lateral",
"toast.workspace.disabled.title": "Espaços de trabalho desativados",
"toast.workspace.disabled.description": "Apenas a worktree principal é exibida na barra lateral",
"toast.permissions.autoaccept.on.title": "Aceitando edições automaticamente",
"toast.permissions.autoaccept.on.description": "Permissões de edição e escrita serão aprovadas automaticamente",
"toast.permissions.autoaccept.off.title": "Parou de aceitar edições automaticamente",
"toast.permissions.autoaccept.off.description": "Permissões de edição e escrita exigirão aprovação",
"toast.model.none.title": "Nenhum modelo selecionado",
"toast.model.none.description": "Conecte um provedor para resumir esta sessão",
"toast.file.loadFailed.title": "Falha ao carregar arquivo",
"toast.file.listFailed.title": "Falha ao listar arquivos",
"toast.context.noLineSelection.title": "Nenhuma seleção de linhas",
"toast.context.noLineSelection.description": "Selecione primeiro um intervalo de linhas em uma aba de arquivo.",
@@ -353,19 +376,15 @@ export const dict = {
"toast.session.share.success.description": "URL compartilhada copiada para a área de transferência!",
"toast.session.share.failed.title": "Falha ao compartilhar sessão",
"toast.session.share.failed.description": "Ocorreu um erro ao compartilhar a sessão",
"toast.session.unshare.success.title": "Sessão não compartilhada",
"toast.session.unshare.success.description": "Sessão deixou de ser compartilhada com sucesso!",
"toast.session.unshare.failed.title": "Falha ao parar de compartilhar sessão",
"toast.session.unshare.failed.description": "Ocorreu um erro ao parar de compartilhar a sessão",
"toast.session.listFailed.title": "Falha ao carregar sessões para {{project}}",
"toast.update.title": "Atualização disponível",
"toast.update.description": "Uma nova versão do OpenCode ({{version}}) está disponível para instalação.",
"toast.update.action.installRestart": "Instalar e reiniciar",
"toast.update.action.notYet": "Agora não",
"error.page.title": "Algo deu errado",
"error.page.description": "Ocorreu um erro ao carregar a aplicação.",
"error.page.details.label": "Detalhes do Erro",
@@ -376,12 +395,10 @@ export const dict = {
"error.page.report.prefix": "Por favor, reporte este erro para a equipe do OpenCode",
"error.page.report.discord": "no Discord",
"error.page.version": "Versão: {{version}}",
"error.dev.rootNotFound":
"Elemento raiz não encontrado. Você esqueceu de adicioná-lo ao seu index.html? Ou talvez o atributo id foi escrito incorretamente?",
"error.globalSync.connectFailed": "Não foi possível conectar ao servidor. Há um servidor executando em `{{url}}`?",
"directory.error.invalidUrl": "Diretório inválido na URL.",
"error.chain.unknown": "Erro desconhecido",
"error.chain.causedBy": "Causado por:",
"error.chain.apiError": "Erro de API",
@@ -403,21 +420,17 @@ export const dict = {
"error.chain.configFrontmatterError": "Falha ao analisar frontmatter em {{path}}:\n{{message}}",
"error.chain.configInvalid": "Arquivo de configuração em {{path}} é inválido",
"error.chain.configInvalidWithMessage": "Arquivo de configuração em {{path}} é inválido: {{message}}",
"notification.permission.title": "Permissão necessária",
"notification.permission.description": "{{sessionTitle}} em {{projectName}} precisa de permissão",
"notification.question.title": "Pergunta",
"notification.question.description": "{{sessionTitle}} em {{projectName}} tem uma pergunta",
"notification.action.goToSession": "Ir para sessão",
"notification.session.responseReady.title": "Resposta pronta",
"notification.session.error.title": "Erro na sessão",
"notification.session.error.fallbackDescription": "Ocorreu um erro",
"home.recentProjects": "Projetos recentes",
"home.empty.title": "Nenhum projeto recente",
"home.empty.description": "Comece abrindo um projeto local",
"session.tab.session": "Sessão",
"session.tab.review": "Revisão",
"session.tab.context": "Contexto",
@@ -436,17 +449,18 @@ export const dict = {
"session.messages.loadEarlier": "Carregar mensagens anteriores",
"session.messages.loading": "Carregando mensagens...",
"session.messages.jumpToLatest": "Ir para a mais recente",
"session.context.addToContext": "Adicionar {{selection}} ao contexto",
"session.new.worktree.main": "Branch principal",
"session.new.worktree.mainWithBranch": "Branch principal ({{branch}})",
"session.new.worktree.create": "Criar novo worktree",
"session.new.lastModified": "Última modificação",
"session.header.search.placeholder": "Buscar {{project}}",
"session.header.searchFiles": "Buscar arquivos",
"session.header.openIn": "Abrir em",
"session.header.open.action": "Abrir {{app}}",
"session.header.open.ariaLabel": "Abrir em {{app}}",
"session.header.open.menu": "Opções de abertura",
"session.header.open.copyPath": "Copiar caminho",
"status.popover.trigger": "Status",
"status.popover.ariaLabel": "Configurações de servidores",
"status.popover.tab.servers": "Servidores",
@@ -454,7 +468,6 @@ export const dict = {
"status.popover.tab.lsp": "LSP",
"status.popover.tab.plugins": "Plugins",
"status.popover.action.manageServers": "Gerenciar servidores",
"session.share.popover.title": "Publicar na web",
"session.share.popover.description.shared":
"Esta sessão é pública na web. Está acessível para qualquer pessoa com o link.",
@@ -468,10 +481,8 @@ export const dict = {
"session.share.action.view": "Ver",
"session.share.copy.copied": "Copiado",
"session.share.copy.copyLink": "Copiar link",
"lsp.tooltip.none": "Nenhum servidor LSP",
"lsp.label.connected": "{{count}} LSP",
"prompt.loading": "Carregando prompt...",
"terminal.loading": "Carregando terminal...",
"terminal.title": "Terminal",
@@ -480,7 +491,6 @@ export const dict = {
"terminal.connectionLost.title": "Conexão Perdida",
"terminal.connectionLost.description":
"A conexão do terminal foi interrompida. Isso pode acontecer quando o servidor reinicia.",
"common.closeTab": "Fechar aba",
"common.dismiss": "Descartar",
"common.requestFailed": "Requisição falhou",
@@ -494,7 +504,6 @@ export const dict = {
"common.edit": "Editar",
"common.loadMore": "Carregar mais",
"common.key.esc": "ESC",
"sidebar.menu.toggle": "Alternar menu",
"sidebar.nav.projectsAndSessions": "Projetos e sessões",
"sidebar.settings": "Configurações",
@@ -506,18 +515,19 @@ export const dict = {
"sidebar.gettingStarted.line2": "Conecte qualquer provedor para usar modelos, incluindo Claude, GPT, Gemini etc.",
"sidebar.project.recentSessions": "Sessões recentes",
"sidebar.project.viewAllSessions": "Ver todas as sessões",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Desktop",
"settings.section.server": "Servidor",
"settings.tab.general": "Geral",
"settings.tab.shortcuts": "Atalhos",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Executar o servidor OpenCode dentro do WSL no Windows.",
"settings.general.section.appearance": "Aparência",
"settings.general.section.notifications": "Notificações do sistema",
"settings.general.section.updates": "Atualizações",
"settings.general.section.sounds": "Efeitos sonoros",
"settings.general.section.display": "Tela",
"settings.general.row.language.title": "Idioma",
"settings.general.row.language.description": "Alterar o idioma de exibição do OpenCode",
"settings.general.row.appearance.title": "Aparência",
@@ -526,10 +536,12 @@ export const dict = {
"settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.",
"settings.general.row.font.title": "Fonte",
"settings.general.row.font.description": "Personalize a fonte monoespaçada usada em blocos de código",
"settings.general.row.wayland.title": "Usar Wayland nativo",
"settings.general.row.wayland.description": "Desabilitar fallback X11 no Wayland. Requer reinicialização.",
"settings.general.row.wayland.tooltip":
"No Linux com monitores de taxas de atualização mistas, Wayland nativo pode ser mais estável.",
"settings.general.row.releaseNotes.title": "Notas da versão",
"settings.general.row.releaseNotes.description": 'Mostrar pop-ups de "Novidades" após atualizações',
"settings.updates.row.startup.title": "Verificar atualizações ao iniciar",
"settings.updates.row.startup.description": "Verificar atualizações automaticamente quando o OpenCode iniciar",
"settings.updates.row.check.title": "Verificar atualizações",
@@ -595,7 +607,6 @@ export const dict = {
"sound.option.yup04": "Sim 04",
"sound.option.yup05": "Sim 05",
"sound.option.yup06": "Sim 06",
"settings.general.notifications.agent.title": "Agente",
"settings.general.notifications.agent.description":
"Mostrar notificação do sistema quando o agente estiver completo ou precisar de atenção",
@@ -604,14 +615,12 @@ export const dict = {
"Mostrar notificação do sistema quando uma permissão for necessária",
"settings.general.notifications.errors.title": "Erros",
"settings.general.notifications.errors.description": "Mostrar notificação do sistema quando ocorrer um erro",
"settings.general.sounds.agent.title": "Agente",
"settings.general.sounds.agent.description": "Reproduzir som quando o agente estiver completo ou precisar de atenção",
"settings.general.sounds.permissions.title": "Permissões",
"settings.general.sounds.permissions.description": "Reproduzir som quando uma permissão for necessária",
"settings.general.sounds.errors.title": "Erros",
"settings.general.sounds.errors.description": "Reproduzir som quando ocorrer um erro",
"settings.shortcuts.title": "Atalhos de teclado",
"settings.shortcuts.reset.button": "Redefinir para padrões",
"settings.shortcuts.reset.toast.title": "Atalhos redefinidos",
@@ -622,14 +631,12 @@ export const dict = {
"settings.shortcuts.pressKeys": "Pressione teclas",
"settings.shortcuts.search.placeholder": "Buscar atalhos",
"settings.shortcuts.search.empty": "Nenhum atalho encontrado",
"settings.shortcuts.group.general": "Geral",
"settings.shortcuts.group.session": "Sessão",
"settings.shortcuts.group.navigation": "Navegação",
"settings.shortcuts.group.modelAndAgent": "Modelo e agente",
"settings.shortcuts.group.terminal": "Terminal",
"settings.shortcuts.group.prompt": "Prompt",
"settings.providers.title": "Provedores",
"settings.providers.description": "Configurações de provedores estarão disponíveis aqui.",
"settings.providers.section.connected": "Provedores conectados",
@@ -647,16 +654,13 @@ export const dict = {
"settings.commands.description": "Configurações de comandos estarão disponíveis aqui.",
"settings.mcp.title": "MCP",
"settings.mcp.description": "Configurações de MCP estarão disponíveis aqui.",
"settings.permissions.title": "Permissões",
"settings.permissions.description": "Controle quais ferramentas o servidor pode usar por padrão.",
"settings.permissions.section.tools": "Ferramentas",
"settings.permissions.toast.updateFailed.title": "Falha ao atualizar permissões",
"settings.permissions.action.allow": "Permitir",
"settings.permissions.action.ask": "Perguntar",
"settings.permissions.action.deny": "Negar",
"settings.permissions.tool.read.title": "Ler",
"settings.permissions.tool.read.description": "Ler um arquivo (corresponde ao caminho do arquivo)",
"settings.permissions.tool.edit.title": "Editar",
@@ -690,12 +694,10 @@ export const dict = {
"settings.permissions.tool.external_directory.description": "Acessar arquivos fora do diretório do projeto",
"settings.permissions.tool.doom_loop.title": "Loop Infinito",
"settings.permissions.tool.doom_loop.description": "Detectar chamadas de ferramentas repetidas com entrada idêntica",
"session.delete.failed.title": "Falha ao excluir sessão",
"session.delete.title": "Excluir sessão",
"session.delete.confirm": 'Excluir sessão "{{name}}"?',
"session.delete.button": "Excluir sessão",
"workspace.new": "Novo espaço de trabalho",
"workspace.type.local": "local",
"workspace.type.sandbox": "sandbox",

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