Compare commits

...

94 Commits

Author SHA1 Message Date
Sebastian Herrlinger
8da0e61d38 docs 2026-02-13 23:45:50 +01:00
Sebastian Herrlinger
059a6b3f8b plugin renderer types 2026-02-13 23:45:34 +01:00
Sebastian Herrlinger
9a5cf7dfe5 stash 2026-02-13 23:22:45 +01:00
Sebastian Herrlinger
27090c122d split config 2026-02-13 22:02:34 +01:00
Kit Langton
d30e917385 fix(ui): support cmd-click links in inline code (#12552) 2026-02-13 13:57:38 -06:00
Niu Shuai
72c09e1dcc fix: standardize zh-CN docs character set and terminology (#13500) 2026-02-13 12:58:12 -06:00
严浩
bc1fd0633d fix(test): move timeout config to CLI flag (#13494)
Co-authored-by: 严浩 <h_mini2024@oo1.dev>
2026-02-13 10:20:02 -05:00
G36maid
88e2eb5416 docs: add pacman installation option for Arch Linux alongside AUR (#13293) 2026-02-13 09:07:48 -06:00
Jun
b8848cfae1 docs(ko): polish Korean phrasing in acp, agents, config, and custom-tools docs (#13446) 2026-02-13 09:05:29 -06:00
Adam
4f51c0912d chore: cleanup 2026-02-13 05:52:43 -06:00
Adam
1c71604e0a fix(app): terminal resize 2026-02-13 05:52:42 -06:00
eytans
e242fe19e4 fix(web): use prompt_async endpoint to avoid timeout over VPN/tunnel (#12749) 2026-02-13 05:25:47 -06:00
opencode-agent[bot]
f991a6c0b6 chore: generate 2026-02-13 11:19:37 +00:00
Annopick
b1764b2ffd docs: Fix zh-cn translation mistake in tools.mdx (#13407) 2026-02-13 05:18:47 -06:00
Chris Yang
ebe5a2b74a fix(app): remount SDK/sync tree when server URL changes (#13437) 2026-02-13 05:16:14 -06:00
Jun
9f20e0d14b fix(web): sync docs locale cookie on alias redirects (#13109) 2026-02-13 05:12:28 -06:00
Filip
ebb907d646 fix(desktop): performance optimization for showing large diff & files (#13460) 2026-02-13 05:08:13 -06:00
opencode-agent[bot]
b8ee882126 chore: update nix node_modules hashes 2026-02-13 07:06:28 +00:00
Rahul Mishra
693127d382 feat(cli): add --dir option to run command (#12443) 2026-02-13 00:59:37 -06:00
Aiden Cline
0d90a22f90 feat: update some ai sdk packages and uuse adaptive reasoning for opus 4.6 on vertex/bedrock/anthropic (#13439) 2026-02-13 00:56:11 -06:00
opencode
34ebe814dd release: v1.1.65 2026-02-13 05:51:04 +00:00
Aiden Cline
1fb6c0b5b3 Revert "fix: token substitution in OPENCODE_CONFIG_CONTENT" (#13429) 2026-02-12 23:24:31 -06:00
Aiden Cline
98aeb60a7f fix: ensure @-ing a dir uses the read tool instead of dead list tool (#13428) 2026-02-12 23:20:33 -06:00
Spoon
1608565c80 feat(hook): add tool.definition hook for plugins to modify tool description and parameters (#4956) 2026-02-12 22:52:17 -06:00
Brendan Allan
b06afd657d ci: remove signpath policy 2026-02-13 10:46:45 +08:00
Adam
dd296f7033 fix(app): reconnect event stream on disconnect 2026-02-12 20:20:24 -06:00
Adam
fb7b2f6b4d feat(app): toggle all provider models 2026-02-12 20:19:26 -06:00
Brendan Allan
e0f1c3c20e cleanup desktop loading page 2026-02-13 10:15:36 +08:00
Adam
dec304a273 fix(app): emoji as avatar 2026-02-12 20:05:58 -06:00
Adam
c9719dff72 fix(app): notification should navigate to session 2026-02-12 20:04:36 -06:00
Adam
7f95cc64c5 fix(app): prompt input quirks 2026-02-12 19:58:57 -06:00
Adam
b525c03d20 chore: cleanup 2026-02-12 19:52:20 -06:00
Adam
8da5fd0a66 fix(app): worktree delete 2026-02-12 19:38:13 -06:00
Adam
0303c29e3f fix(app): failed to create store 2026-02-12 19:38:06 -06:00
Brendan Allan
adb0c4d4f9 desktop: only show loading window if sqlite migration is necessary 2026-02-13 08:49:52 +08:00
projectArtur
991496a753 fix: resolve ACP hanging indefinitely in thinking state on Windows (#13222)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-02-12 18:20:00 -06:00
opencode
76db218674 release: v1.1.64 2026-02-12 23:18:40 +00:00
Ariane Emory
29671c1397 fix: token substitution in OPENCODE_CONFIG_CONTENT (#13384) 2026-02-12 16:59:44 -06:00
Aiden Cline
f66624fe6e chore: cleanup flag code (#13389) 2026-02-12 22:38:51 +00:00
opencode-agent[bot]
d475fd6137 chore: generate 2026-02-12 22:14:45 +00:00
Smit Chaudhary
93eee0daf4 fix: look for recent model in fallback in cli (#12582) 2026-02-12 16:13:48 -06:00
opencode-agent[bot]
445e0d7676 chore: update nix node_modules hashes 2026-02-12 22:04:31 +00:00
Luke Parker
4018c863e3 fix: baseline CPU detection (#13371) 2026-02-13 07:50:43 +10:00
Luke Parker
a8f2884521 feat: windows selection behavior, manual ctrl+c (#13315) 2026-02-13 07:38:27 +10:00
Sebastian
c0814da785 do not open console on error (#13374) 2026-02-12 21:29:58 +00:00
opencode-agent[bot]
20dcff1e2e chore: generate 2026-02-12 21:23:32 +00:00
Aman Kalra
11dd281c92 docs: update STACKIT provider documentation with typo fix (#13357)
Co-authored-by: amankalra172 <aman.kalra@st.ovgu.de>
2026-02-12 15:22:35 -06:00
Adam
548608b7ad fix(app): terminal pty isolation 2026-02-12 15:15:34 -06:00
Adam
4e0f509e7b feat(app): option to turn off sound effects 2026-02-12 15:03:05 -06:00
Adam
ff3b174c42 fix(app): normalize oauth error messages 2026-02-12 14:58:25 -06:00
Adam
70303d0b42 chore: cleanup 2026-02-12 14:48:09 -06:00
Adam
7ccf223c84 chore: cleanup 2026-02-12 14:43:20 -06:00
Adam
e9b9a62fe4 chore: cleanup 2026-02-12 14:39:02 -06:00
Adam
81c623f26e chore: cleanup 2026-02-12 14:32:31 -06:00
Adam
3696d1ded1 chore: cleanup 2026-02-12 14:24:19 -06:00
Adam
50f208d69f fix(app): suggestion active state broken 2026-02-12 14:17:05 -06:00
Adam
958320f9c1 fix(app): remote http server connections 2026-02-12 13:41:22 -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
314 changed files with 11865 additions and 6536 deletions

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
.github/workflows/sign-cli.yml vendored Normal file
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

View File

@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS و Linux (موصى به، دائما محدث)
brew install opencode # macOS و Linux (صيغة brew الرسمية، تحديث اقل)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # اي نظام
nix run nixpkgs#opencode # او github:anomalyco/opencode لاحدث فرع dev
```

View File

@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS e Linux (recomendado, sempre atualizado)
brew install opencode # macOS e Linux (fórmula oficial do brew, atualiza menos)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # qualquer sistema
nix run nixpkgs#opencode # ou github:anomalyco/opencode para a branch dev mais recente
```

View File

@@ -51,7 +51,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS i Linux (preporučeno, uvijek ažurno)
brew install opencode # macOS i Linux (zvanična brew formula, rjeđe se ažurira)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # Bilo koji OS
nix run nixpkgs#opencode # ili github:anomalyco/opencode za najnoviji dev branch
```

View File

@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS og Linux (anbefalet, altid up to date)
brew install opencode # macOS og Linux (officiel brew formula, opdateres sjældnere)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # alle OS
nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch
```

View File

@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS und Linux (empfohlen, immer aktuell)
brew install opencode # macOS und Linux (offizielle Brew-Formula, seltener aktualisiert)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # jedes Betriebssystem
nix run nixpkgs#opencode # oder github:anomalyco/opencode für den neuesten dev-Branch
```

View File

@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS y Linux (recomendado, siempre al día)
brew install opencode # macOS y Linux (fórmula oficial de brew, se actualiza menos)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # cualquier sistema
nix run nixpkgs#opencode # o github:anomalyco/opencode para la rama dev más reciente
```

View File

@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS et Linux (recommandé, toujours à jour)
brew install opencode # macOS et Linux (formule officielle brew, mise à jour moins fréquente)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # n'importe quel OS
nix run nixpkgs#opencode # ou github:anomalyco/opencode pour la branche dev la plus récente
```

View File

@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS e Linux (consigliato, sempre aggiornato)
brew install opencode # macOS e Linux (formula brew ufficiale, aggiornata meno spesso)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # Qualsiasi OS
nix run nixpkgs#opencode # oppure github:anomalyco/opencode per lultima branch di sviluppo
```

View File

@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS と Linux推奨。常に最新
brew install opencode # macOS と Linux公式 brew formula。更新頻度は低め
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # どのOSでも
nix run nixpkgs#opencode # または github:anomalyco/opencode で最新 dev ブランチ
```

View File

@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 및 Linux (권장, 항상 최신)
brew install opencode # macOS 및 Linux (공식 brew formula, 업데이트 빈도 낮음)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # 어떤 OS든
nix run nixpkgs#opencode # 또는 github:anomalyco/opencode 로 최신 dev 브랜치
```

View File

@@ -51,7 +51,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
brew install opencode # macOS and Linux (official brew formula, updated less)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # Any OS
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
```

View File

@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS og Linux (anbefalt, alltid oppdatert)
brew install opencode # macOS og Linux (offisiell brew-formel, oppdateres sjeldnere)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # alle OS
nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch
```

View File

@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS i Linux (polecane, zawsze aktualne)
brew install opencode # macOS i Linux (oficjalna formuła brew, rzadziej aktualizowana)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # dowolny system
nix run nixpkgs#opencode # lub github:anomalyco/opencode dla najnowszej gałęzi dev
```

View File

@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS и Linux (рекомендуем, всегда актуально)
brew install opencode # macOS и Linux (официальная формула brew, обновляется реже)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # любая ОС
nix run nixpkgs#opencode # или github:anomalyco/opencode для самой свежей ветки dev
```

View File

@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS และ Linux (แนะนำ อัปเดตเสมอ)
brew install opencode # macOS และ Linux (brew formula อย่างเป็นทางการ อัปเดตน้อยกว่า)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # ระบบปฏิบัติการใดก็ได้
nix run nixpkgs#opencode # หรือ github:anomalyco/opencode สำหรับสาขาพัฒนาล่าสุด
```

View File

@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS ve Linux (önerilir, her zaman güncel)
brew install opencode # macOS ve Linux (resmi brew formülü, daha az güncellenir)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # Tüm işletim sistemleri
nix run nixpkgs#opencode # veya en güncel geliştirme dalı için github:anomalyco/opencode
```

View File

@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 和 Linux推荐始终保持最新
brew install opencode # macOS 和 Linux官方 brew formula更新频率较低
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # 任意系统
nix run nixpkgs#opencode # 或用 github:anomalyco/opencode 获取最新 dev 分支
```

View File

@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 與 Linux推薦始終保持最新
brew install opencode # macOS 與 Linux官方 brew formula更新頻率較低
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # 任何作業系統
nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支
```

View File

@@ -23,7 +23,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.59",
"version": "1.1.65",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -73,7 +73,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.59",
"version": "1.1.65",
"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.59",
"version": "1.1.65",
"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.59",
"version": "1.1.65",
"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.59",
"version": "1.1.65",
"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.59",
"version": "1.1.65",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -215,7 +215,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.59",
"version": "1.1.65",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -244,7 +244,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.59",
"version": "1.1.65",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -260,7 +260,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.59",
"version": "1.1.65",
"bin": {
"opencode": "./bin/opencode",
},
@@ -268,12 +268,12 @@
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.14.1",
"@ai-sdk/amazon-bedrock": "3.0.74",
"@ai-sdk/anthropic": "2.0.58",
"@ai-sdk/amazon-bedrock": "3.0.79",
"@ai-sdk/anthropic": "2.0.62",
"@ai-sdk/azure": "2.0.91",
"@ai-sdk/cerebras": "1.0.36",
"@ai-sdk/cohere": "2.0.22",
"@ai-sdk/deepinfra": "1.0.33",
"@ai-sdk/deepinfra": "1.0.36",
"@ai-sdk/gateway": "2.0.30",
"@ai-sdk/google": "2.0.52",
"@ai-sdk/google-vertex": "3.0.98",
@@ -366,9 +366,10 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.59",
"version": "1.1.65",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@opentui/core": "0.1.79",
"zod": "catalog:",
},
"devDependencies": {
@@ -386,7 +387,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.59",
"version": "1.1.65",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -397,7 +398,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.59",
"version": "1.1.65",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -410,7 +411,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.59",
"version": "1.1.65",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -452,7 +453,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.59",
"version": "1.1.65",
"dependencies": {
"zod": "catalog:",
},
@@ -463,7 +464,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.59",
"version": "1.1.65",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -513,7 +514,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",
@@ -565,7 +566,7 @@
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.14.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.74", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-q83HE3FBb/HPIvjXsehrHOgCuGHPorSMFt6BYnzIYZy8gNnSqV1OWX4oXVsCAuYPPMtYW/KMK35hmoIFV8QKoQ=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.79", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.62", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-GfAQUb1GEmdTjLu5Ud1d5sieNHDpwoQdb4S14KmJlA5RsGREUZ1tfSKngFaiClxFtL0xPSZjePhTMV6Z65A7/g=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w=="],
@@ -577,7 +578,7 @@
"@ai-sdk/deepgram": ["@ai-sdk/deepgram@1.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-lqmINr+1Jy2yGXxnQB6IrC2xMtUY5uK96pyKfqTj1kLlXGatKnJfXF7WTkOGgQrFqIYqpjDz+sPVR3n0KUEUtA=="],
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hn2y8Q+2iZgGNVJyzPsH8EECECryFMVmxBJrBvBWoi8xcJPRyt0fZP5dOSLyGg3q0oxmPS9M0Eq0NNlKot/bYQ=="],
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.36", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.33", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LndvRktEgY2IFu4peDJMEXcjhHEEFtM0upLx/J64kCpFHCifalXpK4PPSX3PVndnn0bJzvamO5+fc0z2ooqBZw=="],
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NiKjvqXI/96e/7SjZGgQH141PBqggsF7fNbjGTv4RgVWayMXp9mj0Ou2NjAUGwwxJwj/qseY0gXiDCYaHWFBkw=="],
@@ -1409,7 +1410,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=="],
@@ -3339,7 +3340,7 @@
"pagefind": ["pagefind@1.4.0", "", { "optionalDependencies": { "@pagefind/darwin-arm64": "1.4.0", "@pagefind/darwin-x64": "1.4.0", "@pagefind/freebsd-x64": "1.4.0", "@pagefind/linux-arm64": "1.4.0", "@pagefind/linux-x64": "1.4.0", "@pagefind/windows-x64": "1.4.0" }, "bin": { "pagefind": "lib/runner/bin.cjs" } }, "sha512-z2kY1mQlL4J8q5EIsQkLzQjilovKzfNVhX8De6oyE6uHpfFtyBaqUpcl/XzJC/4fjD8vBDyh1zolimIcVrCn9g=="],
"pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="],
@@ -4151,7 +4152,9 @@
"@actions/http-client/undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.62", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-I3RhaOEMnWlWnrvjNBOYvUb19Dwf2nw01IruZrVJRDi688886e11wnd5DxrBZLd2V29Gizo3vpOPnnExsA+wTA=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="],
@@ -4163,7 +4166,9 @@
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2KMcR2xAul3u5dGZD7gONgbIki3Hg7Ey+sFu7gsiJ4U2iRU0GDV3ccNq79dTuAEXPDFcOWCUpW8A8jXc0kxJxQ=="],
"@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
@@ -4387,13 +4392,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=="],
@@ -4457,6 +4458,8 @@
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.74", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-q83HE3FBb/HPIvjXsehrHOgCuGHPorSMFt6BYnzIYZy8gNnSqV1OWX4oXVsCAuYPPMtYW/KMK35hmoIFV8QKoQ=="],
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
"ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.90", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.56", "@ai-sdk/google": "2.0.46", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C9MLe1KZGg1ZbupV2osygHtL5qngyCDA6ATatunyfTbIe8TXKG8HGni/3O6ifbnI5qxTidIn150Ox7eIFZVMYg=="],
@@ -4579,7 +4582,7 @@
"nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.62", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-I3RhaOEMnWlWnrvjNBOYvUb19Dwf2nw01IruZrVJRDi688886e11wnd5DxrBZLd2V29Gizo3vpOPnnExsA+wTA=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
@@ -4671,9 +4674,9 @@
"type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"unicode-trie/pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
@@ -4973,23 +4976,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=="],
@@ -5013,6 +5002,8 @@
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.46", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8PK6u4sGE/kXebd7ZkTp+0aya4kNqzoqpS5m7cHY2NfTK6fhPc6GNvE+MZIZIoHQTp5ed86wGBdeBPpFaaUtyg=="],
@@ -5117,6 +5108,8 @@
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"opencode/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"opencontrol/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="],

16
install
View File

@@ -130,7 +130,7 @@ else
needs_baseline=false
if [ "$arch" = "x64" ]; then
if [ "$os" = "linux" ]; then
if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
if ! grep -qwi avx2 /proc/cpuinfo 2>/dev/null; then
needs_baseline=true
fi
fi
@@ -141,6 +141,20 @@ else
needs_baseline=true
fi
fi
if [ "$os" = "windows" ]; then
ps="(Add-Type -MemberDefinition \"[DllImport(\"\"kernel32.dll\"\")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);\" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)"
out=""
if command -v powershell.exe >/dev/null 2>&1; then
out=$(powershell.exe -NoProfile -NonInteractive -Command "$ps" 2>/dev/null || true)
elif command -v pwsh >/dev/null 2>&1; then
out=$(pwsh -NoProfile -NonInteractive -Command "$ps" 2>/dev/null || true)
fi
out=$(echo "$out" | tr -d '\r' | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
if [ "$out" != "true" ] && [ "$out" != "1" ]; then
needs_baseline=true
fi
fi
fi
target="$os-$arch"

View File

@@ -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"
}

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"}

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-pp2gb4nxiIT3VltB6Xli2wZPH32JfnMsI+BbihyU1+E=",
"aarch64-linux": "sha256-hJwxhBICZz/pbIxQsF/sIpZTlFIgLpcAyF44O8wxMdU=",
"aarch64-darwin": "sha256-DPONXP52XOg/ApdSnLp32a+K5XCOnDGhbTUto2Rme0g=",
"x86_64-darwin": "sha256-KX1h5LRJSgthpbOPqWlbM/sPf8cvQrdRJvxtrz/FzBQ="
"x86_64-linux": "sha256-FsFTitxnN2brebZDBRGJB0NWTOVYDa/QcNRH0ip/Gk4=",
"aarch64-linux": "sha256-knSEqEPyonBUfmGZKTq5Om4HikItWbfPdfT7p6iljzs=",
"aarch64-darwin": "sha256-uRgWfuOlLECRCOszm8XhySiWxu9IdDhpSbosPZPAZVI=",
"x86_64-darwin": "sha256-gHuA+Ud9L+XLvKm5Vp5jCXfZWOtunnmX/lB8vczHsG0="
}
}

View File

@@ -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",

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)

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()
})

View File

@@ -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
}

View File

@@ -11,18 +11,12 @@ import {
cleanupTestProject,
clickMenuItem,
confirmDialog,
openProjectMenu,
openSidebar,
openWorkspaceMenu,
setWorkspacesEnabled,
} from "../actions"
import {
inlineInputSelector,
projectSwitchSelector,
projectWorkspacesToggleSelector,
workspaceItemSelector,
} from "../selectors"
import { dirSlug } from "../utils"
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
import { createSdk, dirSlug } from "../utils"
function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
@@ -143,26 +137,35 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
try {
await withProject(
async () => {
await openSidebar(page)
await withProject(async () => {
await page.goto(`/${nonGitSlug}/session`)
const nonGitButton = page.locator(projectSwitchSelector(nonGitSlug)).first()
await expect(nonGitButton).toBeVisible()
await nonGitButton.click()
await expect(page).toHaveURL(new RegExp(`/${nonGitSlug}/session`))
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
const menu = await openProjectMenu(page, nonGitSlug)
const toggle = menu.locator(projectWorkspacesToggleSelector(nonGitSlug)).first()
const activeDir = base64Decode(slugFromUrl(page.url()))
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
await expect(toggle).toBeVisible()
await expect(toggle).toBeDisabled()
await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
},
{ extra: [nonGit] },
)
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)
}
@@ -256,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()
})
})

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)
})
})

View File

@@ -0,0 +1,43 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { sessionIDFromUrl } from "../actions"
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
// the connection open while the agent works, causing "Failed to fetch" over
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
// Simulate Tailscale/VPN killing the long-lived sync connection
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
await gotoSession()
const token = `E2E_ASYNC_${Date.now()}`
await page.locator(promptSelector).click()
await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())!
try {
// Agent response arrives via SSE despite sync endpoint being dead
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
},
{ timeout: 90_000 },
)
.toContain(token)
} finally {
await sdk.session.delete({ sessionID }).catch(() => undefined)
}
})

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)

View File

@@ -10,8 +10,11 @@ export const settingsNotificationsAgentSelector = '[data-action="settings-notifi
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]'
export const settingsSoundsAgentEnabledSelector = '[data-action="settings-sounds-agent-enabled"]'
export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]'
export const settingsSoundsPermissionsEnabledSelector = '[data-action="settings-sounds-permissions-enabled"]'
export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]'
export const settingsSoundsErrorsEnabledSelector = '[data-action="settings-sounds-errors-enabled"]'
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'

View File

@@ -10,21 +10,26 @@ 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(
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)),
)
@@ -33,21 +38,14 @@ async function seedConversation(input: {
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 }
}

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,
})
})

View File

@@ -9,6 +9,7 @@ import {
settingsNotificationsPermissionsSelector,
settingsReleaseNotesSelector,
settingsSoundsAgentSelector,
settingsSoundsAgentEnabledSelector,
settingsSoundsErrorsSelector,
settingsSoundsPermissionsSelector,
settingsThemeSelector,
@@ -335,6 +336,30 @@ test("changing sound agent selection persists in localStorage", async ({ page, g
expect(stored?.sounds?.agent).not.toBe("staplebops-01")
})
test("disabling agent sound disables sound selection", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const select = dialog.locator(settingsSoundsAgentSelector)
const switchContainer = dialog.locator(settingsSoundsAgentEnabledSelector)
const trigger = select.locator('[data-slot="select-select-trigger"]')
await expect(select).toBeVisible()
await expect(switchContainer).toBeVisible()
await expect(trigger).toBeEnabled()
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
await expect(trigger).toBeDisabled()
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.sounds?.agentEnabled).toBe(false)
})
test("changing permissions and errors sounds updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.59",
"version": "1.1.65",
"description": "",
"type": "module",
"exports": {

View File

@@ -1,5 +1,5 @@
import "@/index.css"
import { ErrorBoundary, Show, lazy, type ParentProps } from "solid-js"
import { ErrorBoundary, Show, 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>
@@ -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>
@@ -86,80 +165,29 @@ function ServerKey(props: ParentProps) {
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>

View File

@@ -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 }) => {
@@ -63,6 +103,24 @@ export function DialogConnectProvider(props: { provider: string }) {
return value.label ?? ""
}
function formatError(value: unknown, fallback: string): string {
if (value && typeof value === "object" && "data" in value) {
const data = (value as { data?: { message?: unknown } }).data
if (typeof data?.message === "string" && data.message) return data.message
}
if (value && typeof value === "object" && "error" in value) {
const nested = formatError((value as { error?: unknown }).error, "")
if (nested) return nested
}
if (value && typeof value === "object" && "message" in value) {
const message = (value as { message?: unknown }).message
if (typeof message === "string" && message) return message
}
if (value instanceof Error && value.message) return value.message
if (typeof value === "string" && value) return value
return fallback
}
async function selectMethod(index: number) {
if (timer.current !== undefined) {
clearTimeout(timer.current)
@@ -70,17 +128,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 +151,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: formatError(e, language.t("common.requestFailed")) })
})
}
}
@@ -129,10 +177,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 +196,243 @@ 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
}
setFormStore("error", formatError(result.error, 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 = formatError(result.error, language.t("common.requestFailed"))
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 +458,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>

View File

@@ -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>

View File

@@ -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>

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 (

View File

@@ -1,6 +1,7 @@
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Button } from "@opencode-ai/ui/button"
import type { Component } from "solid-js"
import { useLocal } from "@/context/local"
@@ -17,6 +18,15 @@ export const DialogManageModels: Component = () => {
const handleConnectProvider = () => {
dialog.show(() => <DialogSelectProvider />)
}
const providerRank = (id: string) => popularProviders.indexOf(id)
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
const providerVisible = (providerID: string) =>
providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id }))
const setProviderVisibility = (providerID: string, checked: boolean) => {
providerList(providerID).forEach((x) => {
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked)
})
}
return (
<Dialog
@@ -35,21 +45,41 @@ export const DialogManageModels: Component = () => {
items={local.model.list()}
filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.name}
groupBy={(x) => x.provider.id}
groupHeader={(group) => {
const provider = group.items[0].provider
return (
<>
<span>{provider.name}</span>
<Tooltip
placement="top"
value={language.t("dialog.model.manage.provider.toggle", { provider: provider.name })}
>
<Switch
class="-mr-1"
checked={providerVisible(provider.id)}
onChange={(checked) => setProviderVisibility(provider.id, checked)}
hideLabel
>
{provider.name}
</Switch>
</Tooltip>
</>
)
}}
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 +87,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)
}}

View File

@@ -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) */}

View File

@@ -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">

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) => {
@@ -289,9 +347,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
tabs().open(value)
file.load(path)
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.open()
layout.fileTree.setTab("all")
props.onOpenFile?.(path)
tabs().setActive(value)
}
const handleSelect = (item: Entry | undefined) => {

View File

@@ -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>

View File

@@ -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"

View File

@@ -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")

View File

@@ -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>

View File

@@ -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

View File

@@ -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>
)

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>
)

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>
)
}

View File

@@ -38,7 +38,12 @@ import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history"
import {
canNavigateHistoryAtCursor,
navigatePromptHistory,
prependHistoryEntry,
promptLength,
} from "./prompt-input/history"
import { createPromptSubmit } from "./prompt-input/submit"
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
import { PromptContextItems } from "./prompt-input/context-items"
@@ -158,14 +163,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
if (wantsReview) {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.open()
layout.fileTree.setTab("changes")
tabs().setActive("review")
requestAnimationFrame(() => comments.setFocus(focus))
return
}
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.open()
layout.fileTree.setTab("all")
const tab = files.tab(item.path)
tabs().open(tab)
@@ -277,6 +281,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 +335,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 +349,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 +427,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")
}
@@ -441,10 +478,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const prev = node.previousSibling
const next = node.nextSibling
const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR"
const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR"
if (!prevIsBr && !nextIsBr) return false
if (nextIsBr && !prevIsBr && prev) return false
return true
return !!prevIsBr && !next
}
if (node.nodeType !== Node.ELEMENT_NODE) return false
const el = node as HTMLElement
@@ -454,7 +488,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))
@@ -464,6 +498,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
editorRef.appendChild(createPill(part))
}
}
const last = editorRef.lastChild
if (last?.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR") {
editorRef.appendChild(document.createTextNode("\u200B"))
}
}
createEffect(
@@ -514,34 +553,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 +655,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 +678,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)
@@ -723,7 +736,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
if (last.nodeType !== Node.TEXT_NODE) {
range.setStartAfter(last)
const isBreak = last.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR"
const next = last.nextSibling
const emptyText = next?.nodeType === Node.TEXT_NODE && (next.textContent ?? "") === ""
if (isBreak && (!next || emptyText)) {
const placeholder = next && emptyText ? next : document.createTextNode("\u200B")
if (!next) last.parentNode?.insertBefore(placeholder, null)
placeholder.textContent = "\u200B"
range.setStart(placeholder, 0)
} else {
range.setStartAfter(last)
}
}
}
range.collapse(true)
@@ -732,7 +755,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
handleInput()
setStore("popover", null)
closePopover()
}
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
@@ -782,8 +805,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 +894,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (ctrl && event.code === "KeyG") {
if (store.popover) {
setStore("popover", null)
closePopover()
event.preventDefault()
return
}
@@ -894,6 +916,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
.current()
.map((part) => ("content" in part ? part.content : ""))
.join("")
const direction = event.key === "ArrowUp" ? "up" : "down"
if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition)) return
const isEmpty = textContent.trim() === "" || textLength <= 1
const hasNewlines = textContent.includes("\n")
const inHistory = store.historyIndex >= 0
@@ -902,7 +926,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const allowUp = isEmpty || atStart || (!hasNewlines && !inHistory) || (inHistory && atEnd)
const allowDown = isEmpty || atEnd || (!hasNewlines && !inHistory) || (inHistory && atStart)
if (event.key === "ArrowUp") {
if (direction === "up") {
if (!allowUp) return
if (navigateHistory("up")) {
event.preventDefault()
@@ -923,7 +947,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
if (event.key === "Escape") {
if (store.popover) {
setStore("popover", null)
closePopover()
} else if (working()) {
abort()
}
@@ -1033,7 +1057,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]"}`}

View File

@@ -89,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 })
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -2,17 +2,26 @@ import { describe, expect, test } from "bun:test"
import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom"
describe("prompt-input editor dom", () => {
test("createTextFragment preserves newlines with br and zero-width placeholders", () => {
test("createTextFragment preserves newlines with consecutive br nodes", () => {
const fragment = createTextFragment("foo\n\nbar")
const container = document.createElement("div")
container.appendChild(fragment)
expect(container.childNodes.length).toBe(5)
expect(container.childNodes.length).toBe(4)
expect(container.childNodes[0]?.textContent).toBe("foo")
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
expect((container.childNodes[2] as HTMLElement).tagName).toBe("BR")
expect(container.childNodes[3]?.textContent).toBe("bar")
})
test("createTextFragment keeps trailing newline as terminal break", () => {
const fragment = createTextFragment("foo\n")
const container = document.createElement("div")
container.appendChild(fragment)
expect(container.childNodes.length).toBe(2)
expect(container.childNodes[0]?.textContent).toBe("foo")
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
expect(container.childNodes[2]?.textContent).toBe("\u200B")
expect((container.childNodes[3] as HTMLElement).tagName).toBe("BR")
expect(container.childNodes[4]?.textContent).toBe("bar")
})
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
@@ -48,4 +57,21 @@ describe("prompt-input editor dom", () => {
container.remove()
})
test("setCursorPosition and getCursorPosition round-trip across blank lines", () => {
const container = document.createElement("div")
container.appendChild(document.createTextNode("a"))
container.appendChild(document.createElement("br"))
container.appendChild(document.createElement("br"))
container.appendChild(document.createTextNode("b"))
document.body.appendChild(container)
setCursorPosition(container, 2)
expect(getCursorPosition(container)).toBe(2)
setCursorPosition(container, 3)
expect(getCursorPosition(container)).toBe(3)
container.remove()
})
})

View File

@@ -4,8 +4,6 @@ export function createTextFragment(content: string): DocumentFragment {
segments.forEach((segment, index) => {
if (segment) {
fragment.appendChild(document.createTextNode(segment))
} else if (segments.length > 1) {
fragment.appendChild(document.createTextNode("\u200B"))
}
if (index < segments.length - 1) {
fragment.appendChild(document.createElement("br"))

View File

@@ -1,6 +1,12 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
import { clonePromptParts, navigatePromptHistory, prependHistoryEntry, promptLength } from "./history"
import {
canNavigateHistoryAtCursor,
clonePromptParts,
navigatePromptHistory,
prependHistoryEntry,
promptLength,
} from "./history"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
@@ -66,4 +72,20 @@ describe("prompt-input history", () => {
if (original[1]?.type !== "file") throw new Error("expected file")
expect(original[1].selection?.startLine).toBe(1)
})
test("canNavigateHistoryAtCursor only allows multiline boundaries", () => {
const value = "a\nb\nc"
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 2)).toBe(false)
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(true)
})
})

View File

@@ -4,6 +4,13 @@ const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
export const MAX_HISTORY = 100
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number) {
if (!text.includes("\n")) return true
const position = Math.max(0, Math.min(cursor, text.length))
if (direction === "up") return !text.slice(0, position).includes("\n")
return !text.slice(position).includes("\n")
}
export function clonePromptParts(prompt: Prompt): Prompt {
return prompt.map((part) => {
if (part.type === "text") return { ...part }

View File

@@ -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>

View File

@@ -52,47 +52,44 @@ 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 key = props.atKey(item)
if (item.type === "agent") {
return (
<button
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
onClick={() => props.onAtSelect(item)}
onMouseEnter={() => props.setAtActive(key)}
>
<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
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
onClick={() => props.onAtSelect(item)}
onMouseEnter={() => props.setAtActive(key)}
>
<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>

View File

@@ -385,7 +385,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const send = async () => {
const ok = await waitForWorktree()
if (!ok) return
await client.session.prompt({
await client.session.promptAsync({
sessionID: session.id,
agent,
model,

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}>

View File

@@ -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"}>

View File

@@ -13,6 +13,17 @@ 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()
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") 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 +52,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 = () => (

View File

@@ -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()
})
})

View File

@@ -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)
}

View File

@@ -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)
},
}
}

View File

@@ -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()
})
})

View File

@@ -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)
}

View File

@@ -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>

View File

@@ -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,93 +301,24 @@ 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 shareUrl = createMemo(() => currentSession()?.share?.url)
createEffect(() => {
const url = shareUrl()
if (url) return
if (state.timer) window.clearTimeout(state.timer)
setState({ copied: false, timer: undefined })
const share = useSessionShare({
globalSDK,
currentSession,
projectDirectory,
platform,
})
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 leftMount = createMemo(
() => document.getElementById("opencode-titlebar-left") ?? document.getElementById("opencode-titlebar-center"),
)
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
return (
<>
<Show when={centerMount()}>
<Show when={leftMount()}>
{(mount) => (
<Portal mount={mount()}>
<button
@@ -382,23 +406,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 />
@@ -428,7 +454,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")
}
@@ -441,24 +467,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>
@@ -467,7 +493,7 @@ export function SessionHeader() {
>
<div class="flex flex-col gap-2">
<TextField
value={shareUrl() ?? ""}
value={share.shareUrl() ?? ""}
readOnly
copyable
copyKind="link"
@@ -479,10 +505,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>
@@ -490,8 +516,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>
@@ -500,10 +526,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")
}
@@ -511,13 +537,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")
}
@@ -526,7 +552,7 @@ export function SessionHeader() {
</Show>
</div>
</Show>
<div class="hidden md:flex items-center gap-3 ml-2 shrink-0">
<div class="flex items-center gap-3 ml-2 shrink-0">
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
@@ -559,7 +585,7 @@ export function SessionHeader() {
</Button>
</TooltipKeybind>
</div>
<div class="hidden md:block shrink-0">
<div class="hidden lg:block shrink-0">
<TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
<Button
variant="ghost"
@@ -589,7 +615,7 @@ export function SessionHeader() {
</Button>
</TooltipKeybind>
</div>
<div class="hidden md:block shrink-0">
<div class="hidden lg:block shrink-0">
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}

View File

@@ -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" />

View File

@@ -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>

View File

@@ -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)}

View File

@@ -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 (

View File

@@ -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 (

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,288 @@ 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")}
>
<div class="flex items-center gap-2">
<div data-action="settings-sounds-agent-enabled">
<Switch
checked={settings.sounds.agentEnabled()}
onChange={(checked) => settings.sounds.setAgentEnabled(checked)}
/>
</div>
<Select
disabled={!settings.sounds.agentEnabled()}
data-action="settings-sounds-agent"
{...soundSelectProps(
() => settings.sounds.agent(),
(id) => settings.sounds.setAgent(id),
)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.permissions.title")}
description={language.t("settings.general.sounds.permissions.description")}
>
<div class="flex items-center gap-2">
<div data-action="settings-sounds-permissions-enabled">
<Switch
checked={settings.sounds.permissionsEnabled()}
onChange={(checked) => settings.sounds.setPermissionsEnabled(checked)}
/>
</div>
<Select
disabled={!settings.sounds.permissionsEnabled()}
data-action="settings-sounds-permissions"
{...soundSelectProps(
() => settings.sounds.permissions(),
(id) => settings.sounds.setPermissions(id),
)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.errors.title")}
description={language.t("settings.general.sounds.errors.description")}
>
<div class="flex items-center gap-2">
<div data-action="settings-sounds-errors-enabled">
<Switch
checked={settings.sounds.errorsEnabled()}
onChange={(checked) => settings.sounds.setErrorsEnabled(checked)}
/>
</div>
<Select
disabled={!settings.sounds.errorsEnabled()}
data-action="settings-sounds-errors"
{...soundSelectProps(
() => settings.sounds.errors(),
(id) => settings.sounds.setErrors(id),
)}
/>
</div>
</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,230 +424,11 @@ 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>
<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>
{/* 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>
</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>
{/* 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>
<SoundsSection />
<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
{(_) => {
@@ -395,53 +458,7 @@ export const SettingsGeneral: Component = () => {
}}
</Show>
{/* 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()}>
{(_) => {

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(() => {

View File

@@ -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 (

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) => (

View File

@@ -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 (

View File

@@ -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

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) => (

View File

@@ -10,6 +10,7 @@ import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@openco
import { useLanguage } from "@/context/language"
import { showToast } from "@opencode-ai/ui/toast"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer"
const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
@@ -56,6 +57,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,13 +156,16 @@ export const Terminal = (props: TerminalProps) => {
let serializeAddon: SerializeAddon
let fitAddon: FitAddon
let handleResize: () => void
let handleTextareaFocus: () => void
let handleTextareaBlur: () => void
let fitFrame: number | undefined
let sizeTimer: ReturnType<typeof setTimeout> | undefined
let pendingSize: { cols: number; rows: number } | undefined
let lastSize: { cols: number; rows: number } | undefined
let disposed = false
const cleanups: VoidFunction[] = []
const start =
typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
let cursor = start ?? 0
let output: ReturnType<typeof terminalWriter> | undefined
const cleanup = () => {
if (!cleanups.length) return
@@ -84,12 +173,23 @@ 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() === "dark" ? "dark" : "light"
const fallback = DEFAULT_TERMINAL_COLORS[mode]
@@ -113,6 +213,43 @@ export const Terminal = (props: TerminalProps) => {
const [terminalColors, setTerminalColors] = createSignal<TerminalColors>(getTerminalColors())
const scheduleFit = () => {
if (disposed) return
if (!fitAddon) return
if (fitFrame !== undefined) return
fitFrame = requestAnimationFrame(() => {
fitFrame = undefined
if (disposed) return
fitAddon.fit()
})
}
const scheduleSize = (cols: number, rows: number) => {
if (disposed) return
if (lastSize?.cols === cols && lastSize?.rows === rows) return
pendingSize = { cols, rows }
if (!lastSize) {
lastSize = pendingSize
void pushSize(cols, rows)
return
}
if (sizeTimer !== undefined) return
sizeTimer = setTimeout(() => {
sizeTimer = undefined
const next = pendingSize
if (!next) return
pendingSize = undefined
if (disposed) return
if (lastSize?.cols === next.cols && lastSize?.rows === next.rows) return
lastSize = next
void pushSize(next.cols, next.rows)
}, 100)
}
createEffect(() => {
const colors = getTerminalColors()
setTerminalColors(colors)
@@ -124,6 +261,16 @@ export const Terminal = (props: TerminalProps) => {
const font = monoFontFamily(settings.appearance.font())
if (!term) return
setOptionIfSupported(term, "fontFamily", font)
scheduleFit()
})
let zoom = platform.webviewZoom?.()
createEffect(() => {
const next = platform.webviewZoom?.()
if (next === undefined) return
if (next === zoom) return
zoom = next
scheduleFit()
})
const focusTerminal = () => {
@@ -167,25 +314,6 @@ export const Terminal = (props: TerminalProps) => {
const once = { value: false }
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
if (window.__OPENCODE__?.serverPassword) {
url.username = "opencode"
url.password = window.__OPENCODE__?.serverPassword
}
const socket = new WebSocket(url)
socket.binaryType = "arraybuffer"
cleanups.push(() => {
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
})
if (disposed) {
cleanup()
return
}
ws = socket
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize =
restore &&
@@ -206,7 +334,7 @@ export const Terminal = (props: TerminalProps) => {
fontSize: 14,
fontFamily: monoFontFamily(settings.appearance.font()),
allowTransparency: false,
convertEol: true,
convertEol: false,
theme: terminalColors(),
scrollback: 10_000,
ghostty: g,
@@ -218,27 +346,7 @@ export const Terminal = (props: TerminalProps) => {
}
ghostty = g
term = t
const handleCopy = (event: ClipboardEvent) => {
const selection = t.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()
t.paste(text)
}
output = terminalWriter((data) => t.write(data))
t.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase()
@@ -255,12 +363,6 @@ export const Terminal = (props: TerminalProps) => {
return matchKeybind(keybinds, event)
})
container.addEventListener("copy", handleCopy, true)
cleanups.push(() => container.removeEventListener("copy", handleCopy, true))
container.addEventListener("paste", handlePaste, true)
cleanups.push(() => container.removeEventListener("paste", handlePaste, true))
const fit = new mod.FitAddon()
const serializer = new SerializeAddon()
cleanups.push(() => disposeIfDisposable(fit))
@@ -270,68 +372,20 @@ 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()
const startResize = () => {
fit.observeResize()
handleResize = () => fit.fit()
window.addEventListener("resize", handleResize)
cleanups.push(() => window.removeEventListener("resize", handleResize))
if (typeof document !== "undefined" && document.fonts) {
document.fonts.ready.then(scheduleFit)
}
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(() => {})
}
const onResize = t.onResize((size) => {
scheduleSize(size.cols, size.rows)
})
cleanups.push(() => disposeIfDisposable(onResize))
const onData = t.onData((data) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(data)
}
if (ws?.readyState === WebSocket.OPEN) ws.send(data)
})
cleanups.push(() => disposeIfDisposable(onData))
const onKey = t.onKey((key) => {
@@ -340,25 +394,64 @@ export const Terminal = (props: TerminalProps) => {
}
})
cleanups.push(() => disposeIfDisposable(onKey))
const startResize = () => {
fit.observeResize()
handleResize = scheduleFit
window.addEventListener("resize", handleResize)
cleanups.push(() => window.removeEventListener("resize", handleResize))
}
if (restore && restoreSize) {
t.write(restore, () => {
fit.fit()
scheduleSize(t.cols, t.rows)
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
startResize()
})
} else {
fit.fit()
scheduleSize(t.cols, t.rows)
if (restore) {
t.write(restore, () => {
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
})
}
startResize()
}
// t.onScroll((ydisp) => {
// console.log("Scroll position:", ydisp)
// })
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
if (window.__OPENCODE__?.serverPassword) {
url.username = "opencode"
url.password = window.__OPENCODE__?.serverPassword
}
const socket = new WebSocket(url)
socket.binaryType = "arraybuffer"
ws = socket
cleanups.push(() => {
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
})
if (disposed) {
cleanup()
return
}
const handleOpen = () => {
local.onConnect?.()
sdk.client.pty
.update({
ptyID: local.pty.id,
size: {
cols: t.cols,
rows: t.rows,
},
})
.catch(() => {})
scheduleSize(t.cols, t.rows)
}
socket.addEventListener("open", handleOpen)
cleanups.push(() => socket.removeEventListener("open", handleOpen))
if (socket.readyState === WebSocket.OPEN) handleOpen()
const decoder = new TextDecoder()
const handleMessage = (event: MessageEvent) => {
@@ -374,15 +467,15 @@ 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
}
const data = typeof event.data === "string" ? event.data : ""
if (!data) return
t.write(data)
output?.push(data)
cursor += data.length
}
socket.addEventListener("message", handleMessage)
@@ -425,25 +518,10 @@ 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(),
})
}
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
output?.flush()
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
cleanup()
})
@@ -457,7 +535,7 @@ export const Terminal = (props: TerminalProps) => {
classList={{
...(local.classList ?? {}),
"select-text": true,
"size-full px-6 py-3 font-mono": true,
"size-full px-6 py-3 font-mono relative overflow-hidden": true,
[local.class ?? ""]: !!local.class,
}}
{...others}

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)
})

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,18 @@ 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)
const modified = event.ctrlKey || event.metaKey || event.altKey
if (palette().has(sig)) {
if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id) && !modified) return
if (isPalette) {
event.preventDefault()
showPalette()
return
}
const option = keymap().get(sig)
if (!option) return
event.preventDefault()
option.onSelect?.("keybind")
@@ -332,7 +357,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 +376,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() {

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()
})
})
})

View File

@@ -1,4 +1,4 @@
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"
@@ -20,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[]>
}
@@ -31,24 +44,24 @@ 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] ?? []
@@ -61,7 +74,6 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
batch(() => {
setStore("comments", input.file, (items) => [...(items ?? []), next])
setState("all", (items) => insert(items, next))
setFocus({ file: input.file, id: next.id })
})
@@ -71,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)
})
@@ -87,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),
}
}
@@ -117,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,
@@ -145,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,
}))
},
@@ -162,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
}

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()

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)
}

View File

@@ -12,28 +12,44 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const platform = usePlatform()
const abort = new AbortController()
const password = typeof window === "undefined" ? undefined : window.__OPENCODE__?.serverPassword
const auth = (() => {
if (typeof window === "undefined") return
const password = window.__OPENCODE__?.serverPassword
if (!password) return
if (!server.isLocal()) return
return {
Authorization: `Basic ${btoa(`opencode:${password}`)}`,
}
})()
const eventFetch = (() => {
if (!platform.fetch) return
try {
const url = new URL(server.url)
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
if (url.protocol === "http:" && !loopback) return platform.fetch
} catch {
return
}
})()
const eventSdk = createOpencodeClient({
baseUrl: server.url,
signal: abort.signal,
headers: auth,
fetch: eventFetch,
headers: eventFetch ? undefined : auth,
})
const emitter = createGlobalEmitter<{
[key: string]: Event
}>()
type Queued = { directory: string; payload: Event }
const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8
const RECONNECT_DELAY_MS = 250
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 +78,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,33 +88,62 @@ 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))
}
void (async () => {
const events = await eventSdk.global.event()
let yielded = Date.now()
for await (const event of events.stream) {
const directory = event.directory ?? "global"
const payload = event.payload
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = undefined
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
let streamErrorLogged = false
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
if (Date.now() - yielded < 8) continue
yielded = Date.now()
await new Promise<void>((resolve) => setTimeout(resolve, 0))
void (async () => {
while (!abort.signal.aborted) {
try {
const events = await eventSdk.global.event({
onSseError: (error) => {
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
url: server.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
},
})
let yielded = Date.now()
for await (const event of events.stream) {
streamErrorLogged = false
const directory = event.directory ?? "global"
const payload = event.payload
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = { directory, payload }
continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
}
} catch (error) {
if (!streamErrorLogged) {
streamErrorLogged = true
console.error("[global-sdk] event stream failed", {
url: server.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
}
}
if (abort.signal.aborted) return
await wait(RECONNECT_DELAY_MS)
}
})()
.finally(flush)
.catch(() => undefined)
})().finally(flush)
onCleanup(() => {
abort.abort()

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,
}
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test"
import { createRoot, getOwner } from "solid-js"
import { createStore } from "solid-js/store"
import type { State } from "./types"
import { createChildStoreManager } from "./child-store"
const child = () => createStore({} as State)
describe("createChildStoreManager", () => {
test("does not evict the active directory during mark", () => {
const owner = createRoot((dispose) => {
const current = getOwner()
dispose()
return current
})
if (!owner) throw new Error("owner required")
const manager = createChildStoreManager({
owner,
markStats() {},
incrementEvictions() {},
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
onDispose() {},
})
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
manager.children[directory] = child()
manager.pin(directory)
})
const directory = "/active"
manager.children[directory] = child()
manager.mark(directory)
expect(manager.children[directory]).toBeDefined()
})
})

View File

@@ -36,7 +36,7 @@ export function createChildStoreManager(input: {
const mark = (directory: string) => {
if (!directory) return
lifecycle.set(directory, { lastAccessAt: Date.now() })
runEviction()
runEviction(directory)
}
const pin = (directory: string) => {
@@ -106,7 +106,7 @@ export function createChildStoreManager(input: {
return true
}
function runEviction() {
function runEviction(skip?: string) {
const stores = Object.keys(children)
if (stores.length === 0) return
const list = pickDirectoriesToEvict({
@@ -116,7 +116,7 @@ export function createChildStoreManager(input: {
max: MAX_DIR_STORES,
ttl: DIR_IDLE_TTL_MS,
now: Date.now(),
})
}).filter((directory) => directory !== skip)
if (list.length === 0) return
for (const directory of list) {
if (!disposeDirectory(directory)) continue

View File

@@ -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)

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