Compare commits

..

57 Commits

Author SHA1 Message Date
Adam
3347b591ce fix(app): better memory management of sessions 2026-02-12 19:12:35 -06:00
opencode
5bbc571276 release: v1.1.64 2026-02-12 18:11:28 -06:00
Ariane Emory
5cc461578b fix: token substitution in OPENCODE_CONFIG_CONTENT (#13384) 2026-02-12 18:11:27 -06:00
Aiden Cline
402cf49164 chore: cleanup flag code (#13389) 2026-02-12 18:11:27 -06:00
opencode-agent[bot]
afe9763668 chore: generate 2026-02-12 18:11:27 -06:00
Smit Chaudhary
e22426303a fix: look for recent model in fallback in cli (#12582) 2026-02-12 18:11:27 -06:00
opencode-agent[bot]
f148ec687e chore: update nix node_modules hashes 2026-02-12 18:11:26 -06:00
Luke Parker
27aa01d1b7 fix: baseline CPU detection (#13371) 2026-02-12 18:11:26 -06:00
Luke Parker
fe4f2b1b23 feat: windows selection behavior, manual ctrl+c (#13315) 2026-02-12 18:11:26 -06:00
Sebastian
14b65ee985 do not open console on error (#13374) 2026-02-12 18:11:26 -06:00
opencode-agent[bot]
e1b90d0340 chore: generate 2026-02-12 18:11:26 -06:00
Aman Kalra
559b2275c0 docs: update STACKIT provider documentation with typo fix (#13357)
Co-authored-by: amankalra172 <aman.kalra@st.ovgu.de>
2026-02-12 18:11:25 -06:00
Adam
20545c98cc fix(app): terminal pty isolation 2026-02-12 18:11:25 -06:00
Adam
2d13bda31a feat(app): option to turn off sound effects 2026-02-12 18:11:25 -06:00
Adam
79ee589099 fix(app): normalize oauth error messages 2026-02-12 18:11:25 -06:00
Adam
8d53d22c36 chore: cleanup 2026-02-12 18:11:25 -06:00
Adam
ddc3032b72 chore: cleanup 2026-02-12 18:11:24 -06:00
Adam
402fc9eed9 chore: cleanup 2026-02-12 18:11:24 -06:00
Adam
f554e5ee7a chore: cleanup 2026-02-12 18:11:24 -06:00
Adam
a21a441409 chore: cleanup 2026-02-12 18:11:24 -06:00
Adam
c6500328ca fix(app): suggestion active state broken 2026-02-12 18:11:24 -06:00
Adam
ff1077b3ad fix(app): remote http server connections 2026-02-12 18:11:23 -06:00
Aiden Cline
90e248f6a0 test: add more test cases for project.test.ts (#13355) 2026-02-12 18:11:23 -06:00
opencode
61455e7d31 release: v1.1.63 2026-02-12 18:11:23 -06:00
Dax Raad
dcce83aaa0 improve codex model list 2026-02-12 18:11:23 -06:00
opencode
79fc63709c release: v1.1.62 2026-02-12 18:11:23 -06:00
Adam
6532b4fb76 fix(app): project icons unloading 2026-02-12 18:11:22 -06:00
Adam
0e7627f637 wip(app): timeline changes 2026-02-12 15:46:08 -06:00
Adam
12a80c4000 wip(app): timeline changes 2026-02-12 15:44:02 -06:00
Adam
713cc7339e wip(app): timeline changes 2026-02-12 15:35:09 -06:00
Adam
4da246ea01 wip(app): timeline changes 2026-02-12 15:19:54 -06:00
Adam
cbf9641642 wip(app): timeline changes 2026-02-12 15:07:55 -06:00
Adam
8e69ff0fe7 wip(app): timeline changes 2026-02-12 14:59:04 -06:00
David Hill
490967208c tweak(ui): show spinners after titles and lock tools until done 2026-02-12 11:38:05 -06:00
David Hill
2fc3bfefc0 fix(ui): improve bash output scrolling and wrapping 2026-02-12 11:38:05 -06:00
David Hill
f10787ef74 tweak(ui): remove horizontal padding from todos and question answers 2026-02-12 11:38:05 -06:00
David Hill
bda07f7d8f refactor(ui): rework bash output container and copy 2026-02-12 11:38:05 -06:00
David Hill
dec46fba39 tweak(ui): expand user message hover area 2026-02-12 11:38:05 -06:00
David Hill
383a0fb896 tweak(ui): add markdown copy tooltip and restyle button 2026-02-12 11:38:04 -06:00
David Hill
532d7e9d80 tweak(ui): prevent copy button layout shift 2026-02-12 11:38:04 -06:00
David Hill
881634f2e7 tweak(ui): adjust tool output spacing and title weight 2026-02-12 11:38:04 -06:00
David Hill
99e7521289 tweak(ui): remove context tool icons and add tool spacing 2026-02-12 11:38:04 -06:00
David Hill
5a663fbd23 tweak(ui): unify tool header typography 2026-02-12 11:38:04 -06:00
David Hill
1760e4fb6e tweak(ui): style edit/write tool content container 2026-02-12 11:38:03 -06:00
David Hill
c0eb553a94 tweak(ui): tighten response copy tooltip gutter 2026-02-12 11:38:03 -06:00
David Hill
c807319f31 tweak(ui): strengthen user message text 2026-02-12 11:38:03 -06:00
David Hill
f16466c996 tweak(ui): refine tool typography and truncation 2026-02-12 11:38:03 -06:00
David Hill
bc501167b2 fix(ui): allow explore agent output to fully expand 2026-02-12 11:38:03 -06:00
David Hill
536d3f73af tweak(ui): tighten chevron spacing 2026-02-12 11:38:02 -06:00
David Hill
594341d8f8 tweak(ui): style agent title and link 2026-02-12 11:38:02 -06:00
David Hill
3ba0265ad8 tweak(ui): flatten tool and collapsible UI 2026-02-12 11:38:02 -06:00
David Hill
e448e77c90 tweak(ui): refine copy tooltip text and spacing 2026-02-12 11:38:02 -06:00
David Hill
1c80f92281 tweak(ui): adjust user message bubble styling 2026-02-12 11:38:01 -06:00
Adam
b10885b557 wip(app): timeline changes 2026-02-12 11:38:01 -06:00
Adam
c31e678391 wip(app): timeline changes 2026-02-12 11:38:01 -06:00
Adam
d238344931 wip(app): timeline changes 2026-02-12 11:38:01 -06:00
Adam
a74d6c3c23 wip(app): timeline changes 2026-02-12 11:38:00 -06:00
291 changed files with 4152 additions and 11967 deletions

View File

@@ -16,12 +16,15 @@ wip:
For anything in the packages/web use the docs: prefix.
For anything in the packages/app use the ignore: prefix.
prefer to explain WHY something was done from an end user perspective instead of
WHAT was done.
do not do generic messages like "improved agent experience" be very specific
about what user facing changes were made
if there are changes do a git pull --rebase
if there are conflicts DO NOT FIX THEM. notify me and I will fix them
## GIT DIFF

View File

@@ -1,5 +1,8 @@
{
"$schema": "https://opencode.ai/config.json",
// "enterprise": {
// "url": "https://enterprise.dev.opencode.ai",
// },
"provider": {
"opencode": {
"options": {},

View File

@@ -1,5 +1,7 @@
github-policies:
runners:
allowed_groups:
- "GitHub Actions"
- "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt"
build:
disallow_reruns: false
branch_rulesets:

View File

@@ -110,4 +110,3 @@ const table = sqliteTable("session", {
- Avoid mocks as much as possible
- Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package dirs like `packages/opencode`.

View File

@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
<a href="README.tr.md">Türkçe</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -51,8 +50,7 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS و Linux (موصى به، دائما محدث)
brew install opencode # macOS و Linux (صيغة brew الرسمية، تحديث اقل)
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
paru -S opencode-bin # Arch Linux
mise use -g opencode # اي نظام
nix run nixpkgs#opencode # او github:anomalyco/opencode لاحدث فرع dev
```

View File

@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
<a href="README.tr.md">Türkçe</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -51,8 +50,7 @@ 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)
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
paru -S opencode-bin # Arch Linux
mise use -g opencode # qualquer sistema
nix run nixpkgs#opencode # ou github:anomalyco/opencode para a branch dev mais recente
```

View File

@@ -32,8 +32,7 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
<a href="README.tr.md">Türkçe</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -52,8 +51,7 @@ 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)
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
paru -S opencode-bin # Arch Linux
mise use -g opencode # Bilo koji OS
nix run nixpkgs#opencode # ili github:anomalyco/opencode za najnoviji dev branch
```

View File

@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
<a href="README.tr.md">Türkçe</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -51,8 +50,7 @@ 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)
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
paru -S opencode-bin # Arch Linux
mise use -g opencode # alle OS
nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch
```

View File

@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
<a href="README.tr.md">Türkçe</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -51,8 +50,7 @@ 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)
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
paru -S opencode-bin # Arch Linux
mise use -g opencode # jedes Betriebssystem
nix run nixpkgs#opencode # oder github:anomalyco/opencode für den neuesten dev-Branch
```

View File

@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
<a href="README.tr.md">Türkçe</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -51,8 +50,7 @@ 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)
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
paru -S opencode-bin # Arch Linux
mise use -g opencode # cualquier sistema
nix run nixpkgs#opencode # o github:anomalyco/opencode para la rama dev más reciente
```

View File

@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
<a href="README.tr.md">Türkçe</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -51,8 +50,7 @@ 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)
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
paru -S opencode-bin # Arch Linux
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

@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
<a href="README.tr.md">Türkçe</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -51,8 +50,7 @@ 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)
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
paru -S opencode-bin # Arch Linux
mise use -g opencode # Qualsiasi OS
nix run nixpkgs#opencode # oppure github:anomalyco/opencode per lultima branch di sviluppo
```

View File

@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
<a href="README.tr.md">Türkçe</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -51,8 +50,7 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS と Linux推奨。常に最新
brew install opencode # macOS と Linux公式 brew formula。更新頻度は低め
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
paru -S opencode-bin # Arch Linux
mise use -g opencode # どのOSでも
nix run nixpkgs#opencode # または github:anomalyco/opencode で最新 dev ブランチ
```

View File

@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
<a href="README.tr.md">Türkçe</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -51,8 +50,7 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 및 Linux (권장, 항상 최신)
brew install opencode # macOS 및 Linux (공식 brew formula, 업데이트 빈도 낮음)
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
paru -S opencode-bin # Arch Linux
mise use -g opencode # 어떤 OS든
nix run nixpkgs#opencode # 또는 github:anomalyco/opencode 로 최신 dev 브랜치
```

View File

@@ -32,8 +32,7 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
<a href="README.tr.md">Türkçe</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -52,8 +51,7 @@ 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)
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
paru -S opencode-bin # Arch Linux
mise use -g opencode # Any OS
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
```

View File

@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
<a href="README.tr.md">Türkçe</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -51,8 +50,7 @@ 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)
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
paru -S opencode-bin # Arch Linux
mise use -g opencode # alle OS
nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch
```

View File

@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
<a href="README.tr.md">Türkçe</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -51,8 +50,7 @@ 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)
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
paru -S opencode-bin # Arch Linux
mise use -g opencode # dowolny system
nix run nixpkgs#opencode # lub github:anomalyco/opencode dla najnowszej gałęzi dev
```

View File

@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
<a href="README.tr.md">Türkçe</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -51,8 +50,7 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS и Linux (рекомендуем, всегда актуально)
brew install opencode # macOS и Linux (официальная формула brew, обновляется реже)
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
paru -S opencode-bin # Arch Linux
mise use -g opencode # любая ОС
nix run nixpkgs#opencode # или github:anomalyco/opencode для самой свежей ветки dev
```

View File

@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
<a href="README.tr.md">Türkçe</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -51,8 +50,7 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS และ Linux (แนะนำ อัปเดตเสมอ)
brew install opencode # macOS และ Linux (brew formula อย่างเป็นทางการ อัปเดตน้อยกว่า)
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
paru -S opencode-bin # Arch Linux
mise use -g opencode # ระบบปฏิบัติการใดก็ได้
nix run nixpkgs#opencode # หรือ github:anomalyco/opencode สำหรับสาขาพัฒนาล่าสุด
```

View File

@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
<a href="README.tr.md">Türkçe</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -51,8 +50,7 @@ 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)
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
paru -S opencode-bin # Arch Linux
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

@@ -1,139 +0,0 @@
<p align="center">
<a href="https://opencode.ai">
<picture>
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
</picture>
</a>
</p>
<p align="center">AI-агент для програмування з відкритим кодом.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh.md">简体中文</a> |
<a href="README.zht.md">繁體中文</a> |
<a href="README.ko.md">한국어</a> |
<a href="README.de.md">Deutsch</a> |
<a href="README.es.md">Español</a> |
<a href="README.fr.md">Français</a> |
<a href="README.it.md">Italiano</a> |
<a href="README.da.md">Dansk</a> |
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.bs.md">Bosanski</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
---
### Встановлення
```bash
# YOLO
curl -fsSL https://opencode.ai/install | bash
# Менеджери пакетів
npm i -g opencode-ai@latest # або bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS і Linux (рекомендовано, завжди актуально)
brew install opencode # macOS і Linux (офіційна формула Homebrew, оновлюється рідше)
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-гілки
```
> [!TIP]
> Перед встановленням видаліть версії старші за 0.1.x.
### Десктопний застосунок (BETA)
OpenCode також доступний як десктопний застосунок. Завантажуйте напряму зі [сторінки релізів](https://github.com/anomalyco/opencode/releases) або [opencode.ai/download](https://opencode.ai/download).
| Платформа | Завантаження |
| --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm` або AppImage |
```bash
# macOS (Homebrew)
brew install --cask opencode-desktop
# Windows (Scoop)
scoop bucket add extras; scoop install extras/opencode-desktop
```
#### Каталог встановлення
Скрипт встановлення дотримується такого порядку пріоритету для шляху встановлення:
1. `$OPENCODE_INSTALL_DIR` - Користувацький каталог встановлення
2. `$XDG_BIN_DIR` - Шлях, сумісний зі специфікацією XDG Base Directory
3. `$HOME/bin` - Стандартний каталог користувацьких бінарників (якщо існує або його можна створити)
4. `$HOME/.opencode/bin` - Резервний варіант за замовчуванням
```bash
# Приклади
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
```
### Агенти
OpenCode містить два вбудовані агенти, між якими можна перемикатися клавішею `Tab`.
- **build** - Агент за замовчуванням із повним доступом для завдань розробки
- **plan** - Агент лише для читання для аналізу та дослідження коду
- За замовчуванням забороняє редагування файлів
- Запитує дозвіл перед запуском bash-команд
- Ідеально підходить для дослідження незнайомих кодових баз або планування змін
Також доступний допоміжний агент **general** для складного пошуку та багатокрокових завдань.
Він використовується всередині системи й може бути викликаний у повідомленнях через `@general`.
Дізнайтеся більше про [agents](https://opencode.ai/docs/agents).
### Документація
Щоб дізнатися більше про налаштування OpenCode, [**перейдіть до нашої документації**](https://opencode.ai/docs).
### Внесок
Якщо ви хочете зробити внесок в OpenCode, будь ласка, прочитайте нашу [документацію для контриб'юторів](./CONTRIBUTING.md) перед надсиланням pull request.
### Проєкти на базі OpenCode
Якщо ви працюєте над проєктом, пов'язаним з OpenCode, і використовуєте "opencode" у назві, наприклад "opencode-dashboard" або "opencode-mobile", додайте примітку до свого README.
Уточніть, що цей проєкт не створений командою OpenCode і жодним чином не афілійований із нами.
### FAQ
#### Чим це відрізняється від Claude Code?
За можливостями це дуже схоже на Claude Code. Ось ключові відмінності:
- 100% open source
- Немає прив'язки до конкретного провайдера. Ми рекомендуємо моделі, які надаємо через [OpenCode Zen](https://opencode.ai/zen), але OpenCode також працює з Claude, OpenAI, Google і навіть локальними моделями. З розвитком моделей різниця між ними зменшуватиметься, а ціни падатимуть, тому незалежність від провайдера має значення.
- Підтримка LSP з коробки
- Фокус на TUI. OpenCode створено користувачами neovim та авторами [terminal.shop](https://terminal.shop); ми й надалі розширюватимемо межі можливого в терміналі.
- Клієнт-серверна архітектура. Наприклад, це дає змогу запускати OpenCode на вашому комп'ютері й керувати ним віддалено з мобільного застосунку, тобто TUI-фронтенд - лише один із можливих клієнтів.
---
**Приєднуйтеся до нашої спільноти** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)

View File

@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
<a href="README.tr.md">Türkçe</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -51,8 +50,7 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 和 Linux推荐始终保持最新
brew install opencode # macOS 和 Linux官方 brew formula更新频率较低
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
paru -S opencode-bin # Arch Linux
mise use -g opencode # 任意系统
nix run nixpkgs#opencode # 或用 github:anomalyco/opencode 获取最新 dev 分支
```

View File

@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> |
<a href="README.uk.md">Українська</a>
<a href="README.tr.md">Türkçe</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -51,8 +50,7 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 與 Linux推薦始終保持最新
brew install opencode # macOS 與 Linux官方 brew formula更新頻率較低
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
paru -S opencode-bin # Arch Linux
mise use -g opencode # 任何作業系統
nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支
```

618
bun.lock

File diff suppressed because it is too large Load Diff

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1770812194,
"narHash": "sha256-OH+lkaIKAvPXR3nITO7iYZwew2nW9Y7Xxq0yfM/UcUU=",
"lastModified": 1770073757,
"narHash": "sha256-Vy+G+F+3E/Tl+GMNgiHl9Pah2DgShmIUBJXmbiQPHbI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "8482c7ded03bae7550f3d69884f1e611e3bd19e8",
"rev": "47472570b1e607482890801aeaf29bfb749884f6",
"type": "github"
},
"original": {

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-C3WIEER2XgzO85wk2sp3BzQ6dknW026zslD8nKZjo2U=",
"aarch64-linux": "sha256-+tTJHZMZ/+8fAjI/1fUTuca8J2MZfB+5vhBoZ7jgqcE=",
"aarch64-darwin": "sha256-vS82puFGBBToxyIBa8Zi0KLKdJYr64T6HZL2rL32mH8=",
"x86_64-darwin": "sha256-Tr8JMTCxV6WVt3dXV7iq3PNCm2Cn+RXAbU9+o7pKKV0="
"x86_64-linux": "sha256-XIf7b6yALzH1/MkGGrsmq2DeXIC9vgD9a7D/dxhi6iU=",
"aarch64-linux": "sha256-mKDCs6QhIelWc3E17zOufaSDTovtjO/Xyh3JtlWl01s=",
"aarch64-darwin": "sha256-wC7bbbIyZ62uMxTr9FElTbEBMrfz0S/ndqwZZ3V9EOA=",
"x86_64-darwin": "sha256-/7Nn65m5Zhvzz0TKsG9nWd2v5WDHQNi3UzCfuAR8SLo="
}
}

View File

@@ -40,8 +40,6 @@
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.12-a5629fb",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
"ai": "5.0.124",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -103,7 +101,6 @@
"@types/node": "catalog:"
},
"patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch"
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch"
}
}

View File

@@ -1,43 +0,0 @@
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

@@ -30,9 +30,6 @@ export const projectMenuTriggerSelector = (slug: string) =>
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
export const projectClearNotificationsSelector = (slug: string) =>
`[data-action="project-clear-notifications"][data-project="${slug}"]`
export const projectWorkspacesToggleSelector = (slug: string) =>
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`

View File

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

View File

@@ -1,5 +1,5 @@
import "@/index.css"
import { ErrorBoundary, Show, Suspense, lazy, type JSX, type ParentProps } from "solid-js"
import { ErrorBoundary, Suspense, lazy, type JSX, type ParentProps } from "solid-js"
import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font"
@@ -156,11 +156,8 @@ export function AppBaseProviders(props: ParentProps) {
function ServerKey(props: ParentProps) {
const server = useServer()
return (
<Show when={server.url} keyed>
{props.children}
</Show>
)
if (!server.url) return null
return props.children
}
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {

View File

@@ -1,7 +1,6 @@
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"
@@ -19,14 +18,6 @@ export const DialogManageModels: Component = () => {
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
@@ -45,28 +36,7 @@ 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.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>
</>
)
}}
groupBy={(x) => x.provider.name}
sortGroupsBy={(a, b) => {
const aRank = providerRank(a.items[0].provider.id)
const bRank = providerRank(b.items[0].provider.id)

View File

@@ -21,8 +21,6 @@ import {
import { Dynamic } from "solid-js/web"
import type { FileNode } from "@opencode-ai/sdk/v2"
const MAX_DEPTH = 128
function pathToFileUrl(filepath: string): string {
return `file://${encodeFilePath(filepath)}`
}
@@ -262,20 +260,12 @@ export default function FileTree(props: {
_marks?: Set<string>
_deeps?: Map<string, number>
_kinds?: ReadonlyMap<string, Kind>
_chain?: readonly string[]
}) {
const file = useFile()
const level = props.level ?? 0
const draggable = () => props.draggable ?? true
const tooltip = () => props.tooltip ?? true
const key = (p: string) =>
file
.normalize(p)
.replace(/[\\/]+$/, "")
.replaceAll("\\", "/")
const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)]
const filter = createMemo(() => {
if (props._filter) return props._filter
@@ -317,45 +307,23 @@ export default function FileTree(props: {
const out = new Map<string, number>()
const root = props.path
if (!(file.tree.state(root)?.expanded ?? false)) return out
const visit = (dir: string, lvl: number): number => {
const expanded = file.tree.state(dir)?.expanded ?? false
if (!expanded) return -1
const seen = new Set<string>()
const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = []
const nodes = file.tree.children(dir)
const max = nodes.reduce((max, node) => {
if (node.type !== "directory") return max
const open = file.tree.state(node.path)?.expanded ?? false
if (!open) return max
return Math.max(max, visit(node.path, lvl + 1))
}, lvl)
const push = (dir: string, lvl: number) => {
const id = key(dir)
if (seen.has(id)) return
seen.add(id)
const kids = file.tree
.children(dir)
.filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false))
.map((node) => node.path)
stack.push({ dir, lvl, i: 0, kids, max: lvl })
}
push(root, level - 1)
while (stack.length > 0) {
const top = stack[stack.length - 1]!
if (top.i < top.kids.length) {
const next = top.kids[top.i]!
top.i++
push(next, top.lvl + 1)
continue
}
out.set(top.dir, top.max)
stack.pop()
const parent = stack[stack.length - 1]
if (!parent) continue
parent.max = Math.max(parent.max, top.max)
out.set(dir, max)
return max
}
visit(props.path, level - 1)
return out
})
@@ -491,27 +459,21 @@ export default function FileTree(props: {
}}
style={`left: ${Math.max(0, 8 + level * 12 - 4) + 8}px`}
/>
<Show
when={level < MAX_DEPTH && !chain.includes(key(node.path))}
fallback={<div class="px-2 py-1 text-12-regular text-text-weak">...</div>}
>
<FileTree
path={node.path}
level={level + 1}
allowed={props.allowed}
modified={props.modified}
kinds={props.kinds}
active={props.active}
draggable={props.draggable}
tooltip={props.tooltip}
onFileClick={props.onFileClick}
_filter={filter()}
_marks={marks()}
_deeps={deeps()}
_kinds={kinds()}
_chain={chain}
/>
</Show>
<FileTree
path={node.path}
level={level + 1}
allowed={props.allowed}
modified={props.modified}
kinds={props.kinds}
active={props.active}
draggable={props.draggable}
tooltip={props.tooltip}
onFileClick={props.onFileClick}
_filter={filter()}
_marks={marks()}
_deeps={deeps()}
_kinds={kinds()}
/>
</Collapsible.Content>
</Collapsible>
</Match>

View File

@@ -38,12 +38,7 @@ 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 {
canNavigateHistoryAtCursor,
navigatePromptHistory,
prependHistoryEntry,
promptLength,
} from "./prompt-input/history"
import { 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"
@@ -478,7 +473,10 @@ 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"
return !!prevIsBr && !next
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
}
if (node.nodeType !== Node.ELEMENT_NODE) return false
const el = node as HTMLElement
@@ -498,11 +496,6 @@ 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(
@@ -736,17 +729,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
if (last.nodeType !== Node.TEXT_NODE) {
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.setStartAfter(last)
}
}
range.collapse(true)
@@ -911,13 +894,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!collapsed) return
const cursorPosition = getCursorPosition(editorRef)
const textLength = promptLength(prompt.current())
const textContent = prompt
.current()
.map((part) => ("content" in part ? part.content : ""))
.join("")
const direction = event.key === "ArrowUp" ? "up" : "down"
if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition, store.historyIndex >= 0)) return
if (navigateHistory(direction)) {
const isEmpty = textContent.trim() === "" || textLength <= 1
const hasNewlines = textContent.includes("\n")
const inHistory = store.historyIndex >= 0
const atStart = cursorPosition <= (isEmpty ? 1 : 0)
const atEnd = cursorPosition >= (isEmpty ? textLength - 1 : textLength)
const allowUp = isEmpty || atStart || (!hasNewlines && !inHistory) || (inHistory && atEnd)
const allowDown = isEmpty || atEnd || (!hasNewlines && !inHistory) || (inHistory && atStart)
if (event.key === "ArrowUp") {
if (!allowUp) return
if (navigateHistory("up")) {
event.preventDefault()
}
return
}
if (!allowDown) return
if (navigateHistory("down")) {
event.preventDefault()
}
return

View File

@@ -2,26 +2,17 @@ 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 consecutive br nodes", () => {
test("createTextFragment preserves newlines with br and zero-width placeholders", () => {
const fragment = createTextFragment("foo\n\nbar")
const container = document.createElement("div")
container.appendChild(fragment)
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.length).toBe(5)
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", () => {
@@ -57,21 +48,4 @@ 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,6 +4,8 @@ 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,12 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
import {
canNavigateHistoryAtCursor,
clonePromptParts,
navigatePromptHistory,
prependHistoryEntry,
promptLength,
} from "./history"
import { clonePromptParts, navigatePromptHistory, prependHistoryEntry, promptLength } from "./history"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
@@ -72,29 +66,4 @@ 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 prompt 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", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 3, true)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 1, true)).toBe(false)
expect(canNavigateHistoryAtCursor("down", "abc", 1, true)).toBe(false)
})
})

View File

@@ -4,15 +4,6 @@ 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, inHistory = false) {
const position = Math.max(0, Math.min(cursor, text.length))
const atStart = position === 0
const atEnd = position === text.length
if (inHistory) return atStart || atEnd
if (direction === "up") return position === 0
return position === text.length
}
export function clonePromptParts(prompt: Prompt): Prompt {
return prompt.map((part) => {
if (part.type === "text") return { ...part }

View File

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

View File

@@ -0,0 +1,85 @@
import type { Todo } from "@opencode-ai/sdk/v2"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { For, Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
function color(status: string) {
if (status === "completed") return "var(--icon-success-base)"
if (status === "in_progress") return "var(--icon-info-base)"
if (status === "cancelled") return "var(--icon-critical-base)"
return "var(--icon-weaker)"
}
export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseLabel: string; expandLabel: string }) {
const [store, setStore] = createStore({
collapsed: false,
})
const progress = createMemo(() => {
const total = props.todos.length
if (total === 0) return ""
const completed = props.todos.filter((todo) => todo.status === "completed").length
return `${completed}/${total}`
})
const preview = createMemo(() => {
const active =
props.todos.find((todo) => todo.status === "in_progress") ??
props.todos.find((todo) => todo.status === "pending") ??
props.todos[0]
if (!active) return ""
return active.content
})
return (
<div class="mb-3 rounded-md border border-border-weak-base bg-surface-raised-stronger-non-alpha shadow-xs-border">
<div class="px-3 py-2 flex items-center gap-2">
<span class="text-12-medium text-text-strong">{props.title}</span>
<Show when={progress()}>
<span class="text-12-regular text-text-weak">{progress()}</span>
</Show>
<div class="ml-auto">
<IconButton
icon="chevron-down"
size="small"
variant="ghost"
classList={{ "rotate-180": !store.collapsed }}
onMouseDown={(event) => event.preventDefault()}
onClick={() => setStore("collapsed", (value) => !value)}
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
/>
</div>
</div>
<Show when={store.collapsed} fallback={<TodoList todos={props.todos} />}>
<div class="px-3 pb-3 text-12-regular text-text-base truncate">{preview()}</div>
</Show>
</div>
)
}
function TodoList(props: { todos: Todo[] }) {
return (
<div class="px-3 pb-3 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar">
<For each={props.todos}>
{(todo) => (
<div class="flex items-start gap-2 min-w-0">
<span style={{ color: color(todo.status) }} class="text-12-medium leading-5 shrink-0">
</span>
<span
class="text-12-regular min-w-0 break-words"
style={{
color: todo.status === "completed" || todo.status === "cancelled" ? "var(--text-weak)" : undefined,
"text-decoration":
todo.status === "completed" || todo.status === "cancelled" ? "line-through" : undefined,
}}
>
{todo.content}
</span>
</div>
)}
</For>
</div>
)
}

View File

@@ -552,7 +552,7 @@ export function SessionHeader() {
</Show>
</div>
</Show>
<div class="flex items-center gap-3 ml-2 shrink-0">
<div class="hidden lg:flex items-center gap-3 ml-2 shrink-0">
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}

View File

@@ -128,7 +128,6 @@ export const SettingsGeneral: Component = () => {
{ value: "roboto-mono", label: "font.option.robotoMono" },
{ value: "source-code-pro", label: "font.option.sourceCodePro" },
{ value: "ubuntu-mono", label: "font.option.ubuntuMono" },
{ value: "geist-mono", label: "font.option.geistMono" },
] as const
const fontOptionsList = [...fontOptions]

View File

@@ -156,10 +156,6 @@ export const Terminal = (props: TerminalProps) => {
let serializeAddon: SerializeAddon
let fitAddon: FitAddon
let handleResize: () => 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 =
@@ -213,43 +209,6 @@ 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)
@@ -261,16 +220,6 @@ 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 = () => {
@@ -314,6 +263,25 @@ 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 &&
@@ -376,28 +344,9 @@ export const Terminal = (props: TerminalProps) => {
focusTerminal()
if (typeof document !== "undefined" && document.fonts) {
document.fonts.ready.then(scheduleFit)
}
const onResize = t.onResize((size) => {
scheduleSize(size.cols, size.rows)
})
cleanups.push(() => disposeIfDisposable(onResize))
const onData = t.onData((data) => {
if (ws?.readyState === WebSocket.OPEN) ws.send(data)
})
cleanups.push(() => disposeIfDisposable(onData))
const onKey = t.onKey((key) => {
if (key.key == "Enter") {
props.onSubmit?.()
}
})
cleanups.push(() => disposeIfDisposable(onKey))
const startResize = () => {
fit.observeResize()
handleResize = scheduleFit
handleResize = () => fit.fit()
window.addEventListener("resize", handleResize)
cleanups.push(() => window.removeEventListener("resize", handleResize))
}
@@ -405,13 +354,11 @@ export const Terminal = (props: TerminalProps) => {
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)
@@ -420,38 +367,35 @@ export const Terminal = (props: TerminalProps) => {
startResize()
}
const onResize = t.onResize(async (size) => {
if (socket.readyState === WebSocket.OPEN) {
await pushSize(size.cols, size.rows)
}
})
cleanups.push(() => disposeIfDisposable(onResize))
const onData = t.onData((data) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(data)
}
})
cleanups.push(() => disposeIfDisposable(onData))
const onKey = t.onKey((key) => {
if (key.key == "Enter") {
props.onSubmit?.()
}
})
cleanups.push(() => disposeIfDisposable(onKey))
// 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?.()
scheduleSize(t.cols, t.rows)
void pushSize(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) => {
@@ -518,8 +462,6 @@ export const Terminal = (props: TerminalProps) => {
onCleanup(() => {
disposed = true
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
output?.flush()
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
cleanup()
@@ -535,7 +477,7 @@ export const Terminal = (props: TerminalProps) => {
classList={{
...(local.classList ?? {}),
"select-text": true,
"size-full px-6 py-3 font-mono relative overflow-hidden": true,
"size-full px-6 py-3 font-mono": true,
[local.class ?? ""]: !!local.class,
}}
{...others}

View File

@@ -316,10 +316,8 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const isPalette = palette().has(sig)
const option = keymap().get(sig)
const modified = event.ctrlKey || event.metaKey || event.altKey
const isTab = event.key === "Tab"
if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id) && !modified && !isTab)
return
if (isEditableTarget(event.target) && !isPalette && !isAllowedEditableKeybind(option?.id) && !modified) return
if (isPalette) {
event.preventDefault()

View File

@@ -46,7 +46,6 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
type Queued = { directory: string; payload: Event }
const FLUSH_FRAME_MS = 16
const STREAM_YIELD_MS = 8
const RECONNECT_DELAY_MS = 250
let queue: Queued[] = []
let buffer: Queued[] = []
@@ -92,58 +91,50 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
}
let streamErrorLogged = false
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
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,
})
},
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,
})
},
})
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] = { directory, payload }
continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
if (abort.signal.aborted) return
await wait(RECONNECT_DELAY_MS)
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
})().finally(flush)
})()
.finally(flush)
.catch((error) => {
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream failed", {
url: server.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
})
onCleanup(() => {
abort.abort()

View File

@@ -4,6 +4,7 @@ import {
type Project,
type ProviderAuthResponse,
type ProviderListResponse,
type Todo,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -41,6 +42,9 @@ type GlobalStore = {
error?: InitError
path: Path
project: Project[]
session_todo: {
[sessionID: string]: Todo[]
}
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
config: Config
@@ -87,12 +91,27 @@ function createGlobalSync() {
ready: false,
path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: projectCache.value,
session_todo: {},
provider: { all: [], connected: [], default: {} },
provider_auth: {},
config: {},
reload: undefined,
})
const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => {
if (!sessionID) return
if (!todos) {
setGlobalStore(
"session_todo",
produce((draft) => {
delete draft[sessionID]
}),
)
return
}
setGlobalStore("session_todo", sessionID, reconcile(todos, { key: "id" }))
}
const updateStats = (activeDirectoryStores: number) => {
if (!import.meta.env.DEV) return
setDevStats({
@@ -283,6 +302,7 @@ function createGlobalSync() {
store,
setStore,
push: queue.push,
setSessionTodo,
vcsCache: children.vcsCache.get(directory),
loadLsp: () => {
sdkFor(directory)
@@ -353,6 +373,9 @@ function createGlobalSync() {
bootstrap,
updateConfig,
project: projectApi,
todo: {
set: setSessionTodo,
},
}
}

View File

@@ -6,6 +6,7 @@ import {
type ProviderAuthResponse,
type ProviderListResponse,
type QuestionRequest,
type Todo,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { batch } from "solid-js"
@@ -20,6 +21,9 @@ type GlobalStore = {
ready: boolean
path: Path
project: Project[]
session_todo: {
[sessionID: string]: Todo[]
}
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
config: Config

View File

@@ -1,39 +0,0 @@
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(directory)
runEviction()
}
const pin = (directory: string) => {
@@ -106,7 +106,7 @@ export function createChildStoreManager(input: {
return true
}
function runEviction(skip?: string) {
function runEviction() {
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

@@ -39,7 +39,12 @@ export function applyGlobalEvent(input: {
})
}
function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, sessionID: string) {
function cleanupSessionCaches(
store: Store<State>,
setStore: SetStoreFunction<State>,
sessionID: string,
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void,
) {
if (!sessionID) return
const hasAny =
store.message[sessionID] !== undefined ||
@@ -48,6 +53,7 @@ function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<St
store.permission[sessionID] !== undefined ||
store.question[sessionID] !== undefined ||
store.session_status[sessionID] !== undefined
setSessionTodo?.(sessionID, undefined)
if (!hasAny) return
setStore(
produce((draft) => {
@@ -77,6 +83,7 @@ export function applyDirectoryEvent(input: {
directory: string
loadLsp: () => void
vcsCache?: VcsCache
setSessionTodo?: (sessionID: string, todos: Todo[] | undefined) => void
}) {
const event = input.event
switch (event.type) {
@@ -110,7 +117,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
cleanupSessionCaches(input.store, input.setStore, info.id)
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@@ -136,7 +143,7 @@ export function applyDirectoryEvent(input: {
}),
)
}
cleanupSessionCaches(input.store, input.setStore, info.id)
cleanupSessionCaches(input.store, input.setStore, info.id, input.setSessionTodo)
if (info.parentID) break
input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
break
@@ -149,6 +156,7 @@ export function applyDirectoryEvent(input: {
case "todo.updated": {
const props = event.properties as { sessionID: string; todos: Todo[] }
input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
input.setSessionTodo?.(props.sessionID, props.todos)
break
}
case "session.status": {
@@ -231,24 +239,6 @@ export function applyDirectoryEvent(input: {
}
break
}
case "message.part.delta": {
const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
const parts = input.store.part[props.messageID]
if (!parts) break
const result = Binary.search(parts, props.partID, (p) => p.id)
if (!result.found) break
input.setStore(
"part",
props.messageID,
produce((draft) => {
const part = draft[result.index]
const field = props.field as keyof typeof part
const existing = part[field] as string | undefined
;(part[field] as string) = (existing ?? "") + props.delta
}),
)
break
}
case "vcs.branch.updated": {
const props = event.properties as { branch: string }
if (input.store.vcs?.branch === props.branch) break

View File

@@ -57,10 +57,6 @@ export type Locale =
type RawDictionary = typeof en & typeof uiEn
type Dictionary = i18n.Flatten<RawDictionary>
function cookie(locale: Locale) {
return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
}
const LOCALES: readonly Locale[] = [
"en",
"zh",
@@ -203,7 +199,6 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
createEffect(() => {
if (typeof document !== "object") return
document.documentElement.lang = locale()
document.cookie = cookie(locale())
})
return {

View File

@@ -85,7 +85,6 @@ const monoFonts: Record<string, string> = {
"roboto-mono": `"Roboto Mono Nerd Font", "RobotoMono Nerd Font", "RobotoMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"source-code-pro": `"Source Code Pro Nerd Font", "SauceCodePro Nerd Font", "SauceCodePro Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"ubuntu-mono": `"Ubuntu Mono Nerd Font", "UbuntuMono Nerd Font", "UbuntuMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"geist-mono": `"GeistMono Nerd Font", "GeistMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
}
export function monoFontFamily(font: string | undefined) {

View File

@@ -106,6 +106,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const messagePageSize = 400
const trimPageSize = 80
const fullSessionLimit = 5
const full = new Map<string, true>()
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
@@ -115,6 +118,112 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
loading: {} as Record<string, boolean>,
})
const touch = (key: string) => {
if (full.has(key)) full.delete(key)
full.set(key, true)
while (full.size > fullSessionLimit) {
const oldest = full.keys().next().value as string | undefined
if (!oldest) return
full.delete(oldest)
}
}
const evict = (input: { directory: string; store: Child[0]; setStore: Setter; keep?: string }) => {
const keep = new Set<string>()
if (input.keep) keep.add(input.keep)
for (const session of input.store.session) {
if (session?.id) keep.add(session.id)
}
const warm = new Set<string>()
for (const sessionID of keep) {
if (full.has(keyFor(input.directory, sessionID))) warm.add(sessionID)
}
if (input.keep) warm.add(input.keep)
const drop = new Set<string>()
const trim = new Set<string>()
for (const sessionID of Object.keys(input.store.message)) {
if (!keep.has(sessionID)) {
drop.add(sessionID)
continue
}
if (!warm.has(sessionID)) trim.add(sessionID)
}
for (const sessionID of Object.keys(input.store.session_diff)) {
if (!keep.has(sessionID) || !warm.has(sessionID)) drop.add(sessionID)
}
for (const sessionID of Object.keys(input.store.todo)) {
if (!keep.has(sessionID) || !warm.has(sessionID)) drop.add(sessionID)
}
for (const sessionID of Object.keys(input.store.permission)) {
if (!keep.has(sessionID)) drop.add(sessionID)
}
for (const sessionID of Object.keys(input.store.question)) {
if (!keep.has(sessionID)) drop.add(sessionID)
}
for (const sessionID of Object.keys(input.store.session_status)) {
if (!keep.has(sessionID)) drop.add(sessionID)
}
if (drop.size === 0 && trim.size === 0) return
input.setStore(
produce((draft) => {
for (const sessionID of drop) {
const messages = draft.message[sessionID]
if (messages) {
for (const message of messages) {
const id = message?.id
if (!id) continue
delete draft.part[id]
}
}
delete draft.message[sessionID]
delete draft.session_diff[sessionID]
delete draft.todo[sessionID]
delete draft.permission[sessionID]
delete draft.question[sessionID]
delete draft.session_status[sessionID]
full.delete(keyFor(input.directory, sessionID))
}
for (const sessionID of trim) {
const messages = draft.message[sessionID]
if (!messages) continue
const count = messages.length - trimPageSize
if (count <= 0) continue
for (const message of messages.slice(0, count)) {
const id = message?.id
if (!id) continue
delete draft.part[id]
}
draft.message[sessionID] = messages.slice(count)
}
}),
)
setMeta(
produce((draft) => {
for (const sessionID of drop) {
const key = keyFor(input.directory, sessionID)
delete draft.limit[key]
delete draft.complete[key]
delete draft.loading[key]
}
for (const sessionID of trim) {
const key = keyFor(input.directory, sessionID)
if (draft.limit[key] !== undefined && draft.limit[key] > trimPageSize) {
draft.limit[key] = trimPageSize
}
if (draft.complete[key] !== undefined) {
draft.complete[key] = false
}
}
}),
)
}
const getSession = (sessionID: string) => {
const store = current()[0]
const match = Binary.search(store.session, sessionID, (s) => s.id)
@@ -236,10 +345,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const hasMessages = store.message[sessionID] !== undefined
const hydrated = meta.limit[key] !== undefined
if (hasSession && hasMessages && hydrated) return
if (hasSession && hasMessages && hydrated && full.has(key)) {
touch(key)
evict({ directory, store, setStore, keep: sessionID })
return
}
const count = store.message[sessionID]?.length ?? 0
const limit = hydrated ? (meta.limit[key] ?? messagePageSize) : limitFor(count)
const limit = hydrated ? Math.max(meta.limit[key] ?? messagePageSize, messagePageSize) : limitFor(count)
const sessionReq = hasSession
? Promise.resolve()
@@ -260,7 +373,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
const messagesReq =
hasMessages && hydrated
hasMessages && hydrated && full.has(key)
? Promise.resolve()
: loadMessages({
directory,
@@ -270,7 +383,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
limit,
})
return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {}))
return runInflight(inflight, key, () =>
Promise.all([sessionReq, messagesReq]).then(() => {
touch(key)
evict({ directory, store, setStore, keep: sessionID })
}),
)
},
async diff(sessionID: string) {
const directory = sdk.directory
@@ -289,12 +407,26 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
if (store.todo[sessionID] !== undefined) return
const existing = store.todo[sessionID]
if (existing !== undefined) {
if (globalSync.data.session_todo[sessionID] === undefined) {
globalSync.todo.set(sessionID, existing)
}
return
}
const cached = globalSync.data.session_todo[sessionID]
if (cached !== undefined) {
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
return
}
const key = keyFor(directory, sessionID)
return runInflight(inflightTodo, key, () =>
retry(() => client.session.todo({ sessionID })).then((todo) => {
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
const list = todo.data ?? []
setStore("todo", sessionID, reconcile(list, { key: "id" }))
globalSync.todo.set(sessionID, list)
}),
)
},

View File

@@ -4,7 +4,6 @@ import { AppBaseProviders, AppInterface } from "@/app"
import { Platform, PlatformProvider } from "@/context/platform"
import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
import { handleNotificationClick } from "@/utils/notification-click"
import pkg from "../package.json"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
@@ -69,7 +68,11 @@ const notify: Platform["notify"] = async (title, description, href) => {
})
notification.onclick = () => {
handleNotificationClick(href)
window.focus()
if (href) {
window.history.pushState(null, "", href)
window.dispatchEvent(new PopStateEvent("popstate"))
}
notification.close()
}
}

View File

@@ -509,7 +509,6 @@ export const dict = {
"sidebar.gettingStarted.line2": "قم بتوصيل أي موفر لاستخدام النماذج، بما في ذلك Claude و GPT و Gemini وما إلى ذلك.",
"sidebar.project.recentSessions": "الجلسات الحديثة",
"sidebar.project.viewAllSessions": "عرض جميع الجلسات",
"sidebar.project.clearNotifications": "مسح الإشعارات",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "سطح المكتب",
"settings.section.server": "الخادم",
@@ -557,7 +556,6 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "تنبيه 01",
"sound.option.alert02": "تنبيه 02",
"sound.option.alert03": "تنبيه 03",

View File

@@ -515,7 +515,6 @@ export const dict = {
"sidebar.gettingStarted.line2": "Conecte qualquer provedor para usar modelos, incluindo Claude, GPT, Gemini etc.",
"sidebar.project.recentSessions": "Sessões recentes",
"sidebar.project.viewAllSessions": "Ver todas as sessões",
"sidebar.project.clearNotifications": "Limpar notificações",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Desktop",
"settings.section.server": "Servidor",
@@ -563,7 +562,6 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Alerta 01",
"sound.option.alert02": "Alerta 02",
"sound.option.alert03": "Alerta 03",

View File

@@ -576,7 +576,6 @@ export const dict = {
"sidebar.gettingStarted.line2": "Poveži bilo kojeg provajdera da koristiš modele, npr. Claude, GPT, Gemini itd.",
"sidebar.project.recentSessions": "Nedavne sesije",
"sidebar.project.viewAllSessions": "Prikaži sve sesije",
"sidebar.project.clearNotifications": "Očisti obavijesti",
"app.name.desktop": "OpenCode Desktop",
@@ -631,7 +630,6 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Upozorenje 01",
"sound.option.alert02": "Upozorenje 02",
"sound.option.alert03": "Upozorenje 03",

View File

@@ -572,7 +572,6 @@ export const dict = {
"sidebar.gettingStarted.line2": "Forbind enhver udbyder for at bruge modeller, inkl. Claude, GPT, Gemini osv.",
"sidebar.project.recentSessions": "Seneste sessioner",
"sidebar.project.viewAllSessions": "Vis alle sessioner",
"sidebar.project.clearNotifications": "Ryd notifikationer",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Desktop",
@@ -627,7 +626,6 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Alarm 01",
"sound.option.alert02": "Alarm 02",
"sound.option.alert03": "Alarm 03",

View File

@@ -524,7 +524,6 @@ export const dict = {
"Verbinden Sie einen beliebigen Anbieter, um Modelle wie Claude, GPT, Gemini usw. zu nutzen.",
"sidebar.project.recentSessions": "Letzte Sitzungen",
"sidebar.project.viewAllSessions": "Alle Sitzungen anzeigen",
"sidebar.project.clearNotifications": "Benachrichtigungen löschen",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Desktop",
"settings.section.server": "Server",
@@ -572,7 +571,6 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Alarm 01",
"sound.option.alert02": "Alarm 02",
"sound.option.alert03": "Alarm 03",

View File

@@ -109,7 +109,6 @@ export const dict = {
"dialog.model.empty": "No model results",
"dialog.model.manage": "Manage models",
"dialog.model.manage.description": "Customize which models appear in the model selector.",
"dialog.model.manage.provider.toggle": "Toggle all {{provider}} models",
"dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode",
"dialog.model.unpaid.addMore.title": "Add more models from popular providers",
@@ -504,6 +503,9 @@ export const dict = {
"session.messages.jumpToLatest": "Jump to latest",
"session.context.addToContext": "Add {{selection}} to context",
"session.todo.title": "Todos",
"session.todo.collapse": "Collapse",
"session.todo.expand": "Expand",
"session.new.worktree.main": "Main branch",
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
@@ -577,7 +579,6 @@ export const dict = {
"sidebar.gettingStarted.line2": "Connect any provider to use models, inc. Claude, GPT, Gemini etc.",
"sidebar.project.recentSessions": "Recent sessions",
"sidebar.project.viewAllSessions": "View all sessions",
"sidebar.project.clearNotifications": "Clear notifications",
"app.name.desktop": "OpenCode Desktop",
@@ -632,7 +633,6 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Alert 01",
"sound.option.alert02": "Alert 02",
"sound.option.alert03": "Alert 03",

View File

@@ -579,7 +579,6 @@ export const dict = {
"sidebar.gettingStarted.line2": "Conecta cualquier proveedor para usar modelos, inc. Claude, GPT, Gemini etc.",
"sidebar.project.recentSessions": "Sesiones recientes",
"sidebar.project.viewAllSessions": "Ver todas las sesiones",
"sidebar.project.clearNotifications": "Borrar notificaciones",
"app.name.desktop": "OpenCode Desktop",
@@ -635,7 +634,6 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Alerta 01",
"sound.option.alert02": "Alerta 02",
"sound.option.alert03": "Alerta 03",

View File

@@ -523,7 +523,6 @@ export const dict = {
"Connectez n'importe quel fournisseur pour utiliser des modèles, y compris Claude, GPT, Gemini etc.",
"sidebar.project.recentSessions": "Sessions récentes",
"sidebar.project.viewAllSessions": "Voir toutes les sessions",
"sidebar.project.clearNotifications": "Effacer les notifications",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Bureau",
"settings.section.server": "Serveur",
@@ -571,7 +570,6 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Alerte 01",
"sound.option.alert02": "Alerte 02",
"sound.option.alert03": "Alerte 03",

View File

@@ -513,7 +513,6 @@ export const dict = {
"sidebar.gettingStarted.line2": "プロバイダーを接続して、Claude、GPT、Geminiなどのモデルを使用できます。",
"sidebar.project.recentSessions": "最近のセッション",
"sidebar.project.viewAllSessions": "すべてのセッションを表示",
"sidebar.project.clearNotifications": "通知をクリア",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "デスクトップ",
"settings.section.server": "サーバー",
@@ -561,7 +560,6 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "アラート 01",
"sound.option.alert02": "アラート 02",
"sound.option.alert03": "アラート 03",

View File

@@ -514,7 +514,6 @@ export const dict = {
"sidebar.gettingStarted.line2": "Claude, GPT, Gemini 등을 포함한 모델을 사용하려면 공급자를 연결하세요.",
"sidebar.project.recentSessions": "최근 세션",
"sidebar.project.viewAllSessions": "모든 세션 보기",
"sidebar.project.clearNotifications": "알림 지우기",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "데스크톱",
"settings.section.server": "서버",
@@ -562,7 +561,6 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "알림 01",
"sound.option.alert02": "알림 02",
"sound.option.alert03": "알림 03",

View File

@@ -579,7 +579,6 @@ export const dict = {
"sidebar.gettingStarted.line2": "Koble til en leverandør for å bruke modeller, inkl. Claude, GPT, Gemini osv.",
"sidebar.project.recentSessions": "Nylige sesjoner",
"sidebar.project.viewAllSessions": "Vis alle sesjoner",
"sidebar.project.clearNotifications": "Fjern varsler",
"app.name.desktop": "OpenCode Desktop",
@@ -634,7 +633,6 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Varsel 01",
"sound.option.alert02": "Varsel 02",
"sound.option.alert03": "Varsel 03",

View File

@@ -514,7 +514,6 @@ export const dict = {
"sidebar.gettingStarted.line2": "Połącz dowolnego dostawcę, aby używać modeli, w tym Claude, GPT, Gemini itp.",
"sidebar.project.recentSessions": "Ostatnie sesje",
"sidebar.project.viewAllSessions": "Zobacz wszystkie sesje",
"sidebar.project.clearNotifications": "Wyczyść powiadomienia",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Pulpit",
"settings.section.server": "Serwer",
@@ -562,7 +561,6 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Alert 01",
"sound.option.alert02": "Alert 02",
"sound.option.alert03": "Alert 03",

View File

@@ -578,7 +578,6 @@ export const dict = {
"Подключите любого провайдера для использования моделей, включая Claude, GPT, Gemini и др.",
"sidebar.project.recentSessions": "Недавние сессии",
"sidebar.project.viewAllSessions": "Посмотреть все сессии",
"sidebar.project.clearNotifications": "Очистить уведомления",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Приложение",
@@ -632,7 +631,6 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "Alert 01",
"sound.option.alert02": "Alert 02",
"sound.option.alert03": "Alert 03",

View File

@@ -571,7 +571,6 @@ export const dict = {
"sidebar.gettingStarted.line2": "เชื่อมต่อผู้ให้บริการใด ๆ เพื่อใช้โมเดล รวมถึง Claude, GPT, Gemini ฯลฯ",
"sidebar.project.recentSessions": "เซสชันล่าสุด",
"sidebar.project.viewAllSessions": "ดูเซสชันทั้งหมด",
"sidebar.project.clearNotifications": "ล้างการแจ้งเตือน",
"app.name.desktop": "OpenCode Desktop",
@@ -626,7 +625,6 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "เสียงเตือน 01",
"sound.option.alert02": "เสียงเตือน 02",
"sound.option.alert03": "เสียงเตือน 03",

View File

@@ -569,7 +569,6 @@ export const dict = {
"sidebar.gettingStarted.line2": "连接任意提供商即可使用更多模型,如 Claude、GPT、Gemini 等。",
"sidebar.project.recentSessions": "最近会话",
"sidebar.project.viewAllSessions": "查看全部会话",
"sidebar.project.clearNotifications": "清除通知",
"app.name.desktop": "OpenCode Desktop",
@@ -623,7 +622,6 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "警报 01",
"sound.option.alert02": "警报 02",

View File

@@ -567,7 +567,6 @@ export const dict = {
"sidebar.gettingStarted.line2": "連線任意提供者即可使用更多模型,如 Claude、GPT、Gemini 等。",
"sidebar.project.recentSessions": "最近工作階段",
"sidebar.project.viewAllSessions": "查看全部工作階段",
"sidebar.project.clearNotifications": "清除通知",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "桌面",
@@ -621,7 +620,6 @@ export const dict = {
"font.option.robotoMono": "Roboto Mono",
"font.option.sourceCodePro": "Source Code Pro",
"font.option.ubuntuMono": "Ubuntu Mono",
"font.option.geistMono": "Geist Mono",
"sound.option.alert01": "警報 01",
"sound.option.alert02": "警報 02",
"sound.option.alert03": "警報 03",

View File

@@ -1,4 +1,3 @@
export { PlatformProvider, type Platform, type DisplayBackend } from "./context/platform"
export { AppBaseProviders, AppInterface } from "./app"
export { useCommand } from "./context/command"
export { handleNotificationClick } from "./utils/notification-click"

View File

@@ -30,7 +30,6 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
onQuestionReject={(input: { requestID: string }) => sdk.client.question.reject(input)}
onNavigateToSession={(sessionID: string) => navigate(`/${params.dir}/session/${sessionID}`)}
onSessionHref={(sessionID: string) => `/${params.dir}/session/${sessionID}`}
onSyncSession={(sessionID: string) => sync.session.sync(sessionID)}
>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>

View File

@@ -1692,13 +1692,6 @@ export default function Layout(props: ParentProps) {
})
const projectId = createMemo(() => panelProps.project?.id ?? "")
const workspaces = createMemo(() => workspaceIds(panelProps.project))
const unseenCount = createMemo(() =>
workspaces().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
)
const clearNotifications = () =>
workspaces()
.filter((directory) => notification.project.unseenCount(directory) > 0)
.forEach((directory) => notification.project.markViewed(directory))
const workspacesEnabled = createMemo(() => {
const project = panelProps.project
if (!project) return false
@@ -1776,16 +1769,6 @@ export default function Layout(props: ParentProps) {
: language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-clear-notifications"
data-project={base64Encode(p().worktree)}
disabled={unseenCount() === 0}
onSelect={clearNotifications}
>
<DropdownMenu.ItemLabel>
{language.t("sidebar.project.clearNotifications")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"

View File

@@ -10,7 +10,6 @@ import { createSortable } from "@thisbeyond/solid-dnd"
import { type LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
import { projectSelected, projectTileActive } from "./sidebar-project-helpers"
@@ -60,7 +59,6 @@ const ProjectTile = (props: {
selected: Accessor<boolean>
active: Accessor<boolean>
overlay: Accessor<boolean>
dirs: Accessor<string[]>
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
onProjectMouseLeave: (worktree: string) => void
onProjectFocus: (worktree: string) => void
@@ -72,94 +70,73 @@ const ProjectTile = (props: {
setMenu: (value: boolean) => void
setOpen: (value: boolean) => void
language: ReturnType<typeof useLanguage>
}): JSX.Element => {
const notification = useNotification()
const unseenCount = createMemo(() =>
props.dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
)
const clear = () =>
props
.dirs()
.filter((directory) => notification.project.unseenCount(directory) > 0)
.forEach((directory) => notification.project.markViewed(directory))
return (
<ContextMenu
modal={!props.sidebarHovering()}
onOpenChange={(value) => {
props.setMenu(value)
if (value) props.setOpen(false)
}): JSX.Element => (
<ContextMenu
modal={!props.sidebarHovering()}
onOpenChange={(value) => {
props.setMenu(value)
if (value) props.setOpen(false)
}}
>
<ContextMenu.Trigger
as="button"
type="button"
aria-label={displayName(props.project)}
data-action="project-switch"
data-project={base64Encode(props.project.worktree)}
classList={{
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": props.selected(),
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
!props.selected() && !props.active(),
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
}}
onMouseEnter={(event: MouseEvent) => {
if (!props.overlay()) return
props.onProjectMouseEnter(props.project.worktree, event)
}}
onMouseLeave={() => {
if (!props.overlay()) return
props.onProjectMouseLeave(props.project.worktree)
}}
onFocus={() => {
if (!props.overlay()) return
props.onProjectFocus(props.project.worktree)
}}
onClick={() => props.navigateToProject(props.project.worktree)}
onBlur={() => props.setOpen(false)}
>
<ContextMenu.Trigger
as="button"
type="button"
aria-label={displayName(props.project)}
data-action="project-switch"
data-project={base64Encode(props.project.worktree)}
classList={{
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": props.selected(),
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
!props.selected() && !props.active(),
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
}}
onMouseEnter={(event: MouseEvent) => {
if (!props.overlay()) return
props.onProjectMouseEnter(props.project.worktree, event)
}}
onMouseLeave={() => {
if (!props.overlay()) return
props.onProjectMouseLeave(props.project.worktree)
}}
onFocus={() => {
if (!props.overlay()) return
props.onProjectFocus(props.project.worktree)
}}
onClick={() => props.navigateToProject(props.project.worktree)}
onBlur={() => props.setOpen(false)}
>
<ProjectIcon project={props.project} notify />
</ContextMenu.Trigger>
<ContextMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(props.project.worktree)}
disabled={props.project.vcs !== "git" && !props.workspacesEnabled(props.project)}
onSelect={() => props.toggleProjectWorkspaces(props.project)}
>
<ContextMenu.ItemLabel>
{props.workspacesEnabled(props.project)
? props.language.t("sidebar.workspaces.disable")
: props.language.t("sidebar.workspaces.enable")}
</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item
data-action="project-clear-notifications"
data-project={base64Encode(props.project.worktree)}
disabled={unseenCount() === 0}
onSelect={clear}
>
<ContextMenu.ItemLabel>{props.language.t("sidebar.project.clearNotifications")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item
data-action="project-close-menu"
data-project={base64Encode(props.project.worktree)}
onSelect={() => props.closeProject(props.project.worktree)}
>
<ContextMenu.ItemLabel>{props.language.t("common.close")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
)
}
<ProjectIcon project={props.project} notify />
</ContextMenu.Trigger>
<ContextMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(props.project.worktree)}
disabled={props.project.vcs !== "git" && !props.workspacesEnabled(props.project)}
onSelect={() => props.toggleProjectWorkspaces(props.project)}
>
<ContextMenu.ItemLabel>
{props.workspacesEnabled(props.project)
? props.language.t("sidebar.workspaces.disable")
: props.language.t("sidebar.workspaces.enable")}
</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item
data-action="project-close-menu"
data-project={base64Encode(props.project.worktree)}
onSelect={() => props.closeProject(props.project.worktree)}
>
<ContextMenu.ItemLabel>{props.language.t("common.close")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
)
const ProjectPreviewPanel = (props: {
project: LocalProject
@@ -277,7 +254,6 @@ export const SortableProject = (props: {
)
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
const [open, setOpen] = createSignal(false)
const [menu, setMenu] = createSignal(false)
@@ -328,7 +304,6 @@ export const SortableProject = (props: {
selected={selected}
active={active}
overlay={overlay}
dirs={dirs}
onProjectMouseEnter={props.ctx.onProjectMouseEnter}
onProjectMouseLeave={props.ctx.onProjectMouseLeave}
onProjectFocus={props.ctx.onProjectFocus}

View File

@@ -20,9 +20,11 @@ import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useSync } from "@/context/sync"
import { useGlobalSync } from "@/context/global-sync"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLayout } from "@/context/layout"
import { checksum, base64Encode } from "@opencode-ai/util/encode"
import { findLast } from "@opencode-ai/util/array"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
import FileTree from "@/components/file-tree"
@@ -34,6 +36,7 @@ import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt"
import { useComments } from "@/context/comments"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { usePermission } from "@/context/permission"
import { showToast } from "@opencode-ai/ui/toast"
import { SessionHeader, SessionContextTab, SortableTab, FileVisual, NewSessionView } from "@/components/session"
import { navMark, navParams } from "@/utils/perf"
@@ -89,6 +92,7 @@ export default function Page() {
const local = useLocal()
const file = useFile()
const sync = useSync()
const globalSync = useGlobalSync()
const terminal = useTerminal()
const dialog = useDialog()
const codeComponent = useCodeComponent()
@@ -99,6 +103,7 @@ export default function Page() {
const sdk = useSDK()
const prompt = usePrompt()
const comments = useComments()
const permission = usePermission()
const permRequest = createMemo(() => {
const sessionID = params.id
@@ -269,7 +274,6 @@ export default function Page() {
if (!path) return
file.load(path)
openReviewPanel()
tabs().setActive(next)
}
createEffect(() => {
@@ -554,7 +558,6 @@ export default function Page() {
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
activeTerminalDraggable: undefined as string | undefined,
expanded: {} as Record<string, boolean>,
messageId: undefined as string | undefined,
turnStart: 0,
mobileTab: "session" as "session" | "changes",
@@ -673,7 +676,8 @@ export default function Page() {
sdk.directory
const id = params.id
if (!id) return
sync.session.sync(id)
void sync.session.sync(id)
void sync.session.todo(id)
})
createEffect(() => {
@@ -726,13 +730,17 @@ export default function Page() {
)
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
const todos = createMemo(() => {
const id = params.id
if (!id) return []
return globalSync.data.session_todo[id] ?? []
})
createEffect(
on(
sessionKey,
() => {
setStore("messageId", undefined)
setStore("expanded", {})
setStore("changes", "session")
setUi("autoCreated", false)
},
@@ -751,12 +759,6 @@ export default function Page() {
),
)
createEffect(() => {
const id = lastUserMessage()?.id
if (!id) return
setStore("expanded", id, status().type !== "idle")
})
const selectionPreview = (path: string, selection: FileSelection) => {
const content = file.get(path)?.content?.content
if (!content) return undefined
@@ -767,6 +769,11 @@ export default function Page() {
return lines.slice(0, 2).join("\n")
}
const addSelectionToContext = (path: string, selection: FileSelection) => {
const preview = selectionPreview(path, selection)
prompt.context.add({ type: "file", path, selection, preview })
}
const addCommentToContext = (input: {
file: string
selection: SelectedLineRange
@@ -905,11 +912,29 @@ export default function Page() {
const focusInput = () => inputRef?.focus()
useSessionCommands({
activeMessage,
command,
dialog,
file,
language,
local,
permission,
prompt,
sdk,
sync,
terminal,
layout,
params,
navigate,
tabs,
view,
info,
status,
userMessages,
visibleUserMessages,
showAllFiles,
navigateMessageByOffset,
setExpanded: (id, fn) => setStore("expanded", id, fn),
setActiveMessage,
addSelectionToContext,
focusInput,
})
@@ -1627,8 +1652,6 @@ export default function Page() {
navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
}}
lastUserMessageID={lastUserMessage()?.id}
expanded={store.expanded}
onToggleExpanded={(id) => setStore("expanded", id, (open: boolean | undefined) => !open)}
/>
</Show>
</Match>
@@ -1659,6 +1682,7 @@ export default function Page() {
questionRequest={questionRequest}
permissionRequest={permRequest}
blocked={blocked()}
todos={todos()}
promptReady={prompt.ready()}
handoffPrompt={handoff.session.get(sessionKey())?.prompt}
t={language.t as (key: string, vars?: Record<string, string | number | boolean>) => string}
@@ -1731,7 +1755,7 @@ export default function Page() {
</div>
<TerminalPanel
open={view().terminal.opened()}
open={isDesktop() && view().terminal.opened()}
height={layout.terminal.height()}
resize={layout.terminal.resize}
close={view().terminal.close}

View File

@@ -1,7 +1,7 @@
import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { sampledChecksum } from "@opencode-ai/util/encode"
import { checksum } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64"
import { showToast } from "@opencode-ai/ui/toast"
import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment"
@@ -49,7 +49,7 @@ export function FileTabContent(props: {
return props.file.get(p)
})
const contents = createMemo(() => state()?.content?.content ?? "")
const cacheKey = createMemo(() => sampledChecksum(contents()))
const cacheKey = createMemo(() => checksum(contents()))
const isImage = createMemo(() => {
const c = state()?.content
return c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
@@ -163,20 +163,11 @@ export function FileTabContent(props: {
return
}
const estimateTop = (range: SelectedLineRange) => {
const line = Math.max(range.start, range.end)
const height = 24
const offset = 2
return Math.max(0, (line - 1) * height + offset)
}
const large = contents().length > 500_000
const next: Record<string, number> = {}
for (const comment of fileComments()) {
const marker = findMarker(root, comment.selection)
if (marker) next[comment.id] = markerTop(el, marker)
else if (large) next[comment.id] = estimateTop(comment.selection)
if (!marker) continue
next[comment.id] = markerTop(el, marker)
}
const removed = Object.keys(note.positions).filter((id) => next[id] === undefined)
@@ -203,12 +194,12 @@ export function FileTabContent(props: {
}
const marker = findMarker(root, range)
if (marker) {
setNote("draftTop", markerTop(el, marker))
if (!marker) {
setNote("draftTop", undefined)
return
}
setNote("draftTop", large ? estimateTop(range) : undefined)
setNote("draftTop", markerTop(el, marker))
}
const scheduleComments = () => {

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { createOpenReviewFile, focusTerminalById, getTabReorderIndex } from "./helpers"
import { combineCommandSections, createOpenReviewFile, focusTerminalById, getTabReorderIndex } from "./helpers"
describe("createOpenReviewFile", () => {
test("opens and loads selected review file", () => {
@@ -46,6 +46,20 @@ describe("focusTerminalById", () => {
})
})
describe("combineCommandSections", () => {
test("keeps section order stable", () => {
const result = combineCommandSections([
[{ id: "a", title: "A" }],
[
{ id: "b", title: "B" },
{ id: "c", title: "C" },
],
])
expect(result.map((item) => item.id)).toEqual(["a", "b", "c"])
})
})
describe("getTabReorderIndex", () => {
test("returns target index for valid drag reorder", () => {
expect(getTabReorderIndex(["a", "b", "c"], "a", "c")).toBe(2)

View File

@@ -1,3 +1,4 @@
import type { CommandOption } from "@/context/command"
import { batch } from "solid-js"
export const focusTerminalById = (id: string) => {
@@ -35,6 +36,10 @@ export const createOpenReviewFile = (input: {
}
}
export const combineCommandSections = (sections: readonly (readonly CommandOption[])[]) => {
return sections.flatMap((section) => section)
}
export const getTabReorderIndex = (tabs: readonly string[], from: string, to: string) => {
const fromIndex = tabs.indexOf(from)
const toIndex = tabs.indexOf(to)

View File

@@ -88,8 +88,6 @@ export function MessageTimeline(props: {
onUnregisterMessage: (id: string) => void
onFirstTurnMount?: () => void
lastUserMessageID?: string
expanded: Record<string, boolean>
onToggleExpanded: (id: string) => void
}) {
let touchGesture: number | undefined
@@ -164,8 +162,9 @@ export function MessageTimeline(props: {
<Show when={props.showHeader}>
<div
classList={{
"sticky top-0 z-30 bg-background-stronger": true,
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
"w-full": true,
"pb-4": true,
"px-4 md:px-6": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
@@ -316,8 +315,6 @@ export function MessageTimeline(props: {
sessionID={props.sessionID}
messageID={message.id}
lastUserMessageID={props.lastUserMessageID}
stepsExpanded={props.expanded[message.id] ?? false}
onStepsExpandedToggle={() => props.onToggleExpanded(message.id)}
classes={{
root: "min-w-0 w-full relative",
content: "flex flex-col justify-between !overflow-visible",

View File

@@ -1,9 +1,10 @@
import { For, Show } from "solid-js"
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
import type { QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { Button } from "@opencode-ai/ui/button"
import { BasicTool } from "@opencode-ai/ui/basic-tool"
import { PromptInput } from "@/components/prompt-input"
import { QuestionDock } from "@/components/question-dock"
import { SessionTodoDock } from "@/components/session-todo-dock"
import { questionSubtitle } from "@/pages/session/session-prompt-helpers"
export function SessionPromptDock(props: {
@@ -11,6 +12,7 @@ export function SessionPromptDock(props: {
questionRequest: () => QuestionRequest | undefined
permissionRequest: () => { patterns: string[]; permission: string } | undefined
blocked: boolean
todos: Todo[]
promptReady: boolean
handoffPrompt?: string
t: (key: string, vars?: Record<string, string | number | boolean>) => string
@@ -122,6 +124,14 @@ export function SessionPromptDock(props: {
</div>
}
>
<Show when={props.todos.length > 0}>
<SessionTodoDock
todos={props.todos}
title={props.t("session.todo.title")}
collapseLabel={props.t("session.todo.collapse")}
expandLabel={props.t("session.todo.expand")}
/>
</Show>
<PromptInput
ref={props.inputRef}
newSessionWorktree={props.newSessionWorktree}

View File

@@ -19,14 +19,33 @@ import { showToast } from "@opencode-ai/ui/toast"
import { findLast } from "@opencode-ai/util/array"
import { extractPromptFromParts } from "@/utils/prompt"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { combineCommandSections } from "@/pages/session/helpers"
import { canAddSelectionContext } from "@/pages/session/session-command-helpers"
export type SessionCommandContext = {
activeMessage: () => UserMessage | undefined
command: ReturnType<typeof useCommand>
dialog: ReturnType<typeof useDialog>
file: ReturnType<typeof useFile>
language: ReturnType<typeof useLanguage>
local: ReturnType<typeof useLocal>
permission: ReturnType<typeof usePermission>
prompt: ReturnType<typeof usePrompt>
sdk: ReturnType<typeof useSDK>
sync: ReturnType<typeof useSync>
terminal: ReturnType<typeof useTerminal>
layout: ReturnType<typeof useLayout>
params: ReturnType<typeof useParams>
navigate: ReturnType<typeof useNavigate>
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
info: () => { revert?: { messageID?: string }; share?: { url?: string } } | undefined
status: () => { type: string }
userMessages: () => UserMessage[]
visibleUserMessages: () => UserMessage[]
showAllFiles: () => void
navigateMessageByOffset: (offset: number) => void
setExpanded: (id: string, fn: (open: boolean | undefined) => boolean) => void
setActiveMessage: (message: UserMessage | undefined) => void
addSelectionToContext: (path: string, selection: FileSelection) => void
focusInput: () => void
}
@@ -37,88 +56,45 @@ const withCategory = (category: string) => {
})
}
export const useSessionCommands = (args: SessionCommandContext) => {
const command = useCommand()
const dialog = useDialog()
const file = useFile()
const language = useLanguage()
const local = useLocal()
const permission = usePermission()
const prompt = usePrompt()
const sdk = useSDK()
const sync = useSync()
const terminal = useTerminal()
const layout = useLayout()
const params = useParams()
const navigate = useNavigate()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const idle = { type: "idle" as const }
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[])
const visibleUserMessages = createMemo(() => {
const revert = info()?.revert?.messageID
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
})
const selectionPreview = (path: string, selection: FileSelection) => {
const content = file.get(path)?.content?.content
if (!content) return undefined
const start = Math.max(1, Math.min(selection.startLine, selection.endLine))
const end = Math.max(selection.startLine, selection.endLine)
const lines = content.split("\n").slice(start - 1, end)
if (lines.length === 0) return undefined
return lines.slice(0, 2).join("\n")
}
const addSelectionToContext = (path: string, selection: FileSelection) => {
const preview = selectionPreview(path, selection)
prompt.context.add({ type: "file", path, selection, preview })
}
const sessionCommand = withCategory(language.t("command.category.session"))
const fileCommand = withCategory(language.t("command.category.file"))
const contextCommand = withCategory(language.t("command.category.context"))
const viewCommand = withCategory(language.t("command.category.view"))
const terminalCommand = withCategory(language.t("command.category.terminal"))
const modelCommand = withCategory(language.t("command.category.model"))
const mcpCommand = withCategory(language.t("command.category.mcp"))
const agentCommand = withCategory(language.t("command.category.agent"))
const permissionsCommand = withCategory(language.t("command.category.permissions"))
export const useSessionCommands = (input: SessionCommandContext) => {
const sessionCommand = withCategory(input.language.t("command.category.session"))
const fileCommand = withCategory(input.language.t("command.category.file"))
const contextCommand = withCategory(input.language.t("command.category.context"))
const viewCommand = withCategory(input.language.t("command.category.view"))
const terminalCommand = withCategory(input.language.t("command.category.terminal"))
const modelCommand = withCategory(input.language.t("command.category.model"))
const mcpCommand = withCategory(input.language.t("command.category.mcp"))
const agentCommand = withCategory(input.language.t("command.category.agent"))
const permissionsCommand = withCategory(input.language.t("command.category.permissions"))
const sessionCommands = createMemo(() => [
sessionCommand({
id: "session.new",
title: language.t("command.session.new"),
title: input.language.t("command.session.new"),
keybind: "mod+shift+s",
slash: "new",
onSelect: () => navigate(`/${params.dir}/session`),
onSelect: () => input.navigate(`/${input.params.dir}/session`),
}),
])
const fileCommands = createMemo(() => [
fileCommand({
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
title: input.language.t("command.file.open"),
description: input.language.t("palette.search.placeholder"),
keybind: "mod+p",
slash: "open",
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={args.showAllFiles} />),
onSelect: () => input.dialog.show(() => <DialogSelectFile onOpenFile={input.showAllFiles} />),
}),
fileCommand({
id: "tab.close",
title: language.t("command.tab.close"),
title: input.language.t("command.tab.close"),
keybind: "mod+w",
disabled: !tabs().active(),
disabled: !input.tabs().active(),
onSelect: () => {
const active = tabs().active()
const active = input.tabs().active()
if (!active) return
tabs().close(active)
input.tabs().close(active)
},
}),
])
@@ -126,30 +102,30 @@ export const useSessionCommands = (args: SessionCommandContext) => {
const contextCommands = createMemo(() => [
contextCommand({
id: "context.addSelection",
title: language.t("command.context.addSelection"),
description: language.t("command.context.addSelection.description"),
title: input.language.t("command.context.addSelection"),
description: input.language.t("command.context.addSelection.description"),
keybind: "mod+shift+l",
disabled: !canAddSelectionContext({
active: tabs().active(),
pathFromTab: file.pathFromTab,
selectedLines: file.selectedLines,
active: input.tabs().active(),
pathFromTab: input.file.pathFromTab,
selectedLines: input.file.selectedLines,
}),
onSelect: () => {
const active = tabs().active()
const active = input.tabs().active()
if (!active) return
const path = file.pathFromTab(active)
const path = input.file.pathFromTab(active)
if (!path) return
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
const range = input.file.selectedLines(path) as SelectedLineRange | null | undefined
if (!range) {
showToast({
title: language.t("toast.context.noLineSelection.title"),
description: language.t("toast.context.noLineSelection.description"),
title: input.language.t("toast.context.noLineSelection.title"),
description: input.language.t("toast.context.noLineSelection.description"),
})
return
}
addSelectionToContext(path, selectionFromLines(range))
input.addSelectionToContext(path, selectionFromLines(range))
},
}),
])
@@ -157,50 +133,37 @@ export const useSessionCommands = (args: SessionCommandContext) => {
const viewCommands = createMemo(() => [
viewCommand({
id: "terminal.toggle",
title: language.t("command.terminal.toggle"),
title: input.language.t("command.terminal.toggle"),
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => view().terminal.toggle(),
onSelect: () => input.view().terminal.toggle(),
}),
viewCommand({
id: "review.toggle",
title: language.t("command.review.toggle"),
title: input.language.t("command.review.toggle"),
keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(),
onSelect: () => input.view().reviewPanel.toggle(),
}),
viewCommand({
id: "fileTree.toggle",
title: language.t("command.fileTree.toggle"),
title: input.language.t("command.fileTree.toggle"),
keybind: "mod+\\",
onSelect: () => layout.fileTree.toggle(),
onSelect: () => input.layout.fileTree.toggle(),
}),
viewCommand({
id: "input.focus",
title: language.t("command.input.focus"),
title: input.language.t("command.input.focus"),
keybind: "ctrl+l",
onSelect: () => args.focusInput(),
onSelect: () => input.focusInput(),
}),
terminalCommand({
id: "terminal.new",
title: language.t("command.terminal.new"),
description: language.t("command.terminal.new.description"),
title: input.language.t("command.terminal.new"),
description: input.language.t("command.terminal.new.description"),
keybind: "ctrl+alt+t",
onSelect: () => {
if (terminal.all().length > 0) terminal.new()
view().terminal.open()
},
}),
viewCommand({
id: "steps.toggle",
title: language.t("command.steps.toggle"),
description: language.t("command.steps.toggle.description"),
keybind: "mod+e",
slash: "steps",
disabled: !params.id,
onSelect: () => {
const msg = args.activeMessage()
if (!msg) return
args.setExpanded(msg.id, (open: boolean | undefined) => !open)
if (input.terminal.all().length > 0) input.terminal.new()
input.view().terminal.open()
},
}),
])
@@ -208,61 +171,61 @@ export const useSessionCommands = (args: SessionCommandContext) => {
const messageCommands = createMemo(() => [
sessionCommand({
id: "message.previous",
title: language.t("command.message.previous"),
description: language.t("command.message.previous.description"),
title: input.language.t("command.message.previous"),
description: input.language.t("command.message.previous.description"),
keybind: "mod+arrowup",
disabled: !params.id,
onSelect: () => args.navigateMessageByOffset(-1),
disabled: !input.params.id,
onSelect: () => input.navigateMessageByOffset(-1),
}),
sessionCommand({
id: "message.next",
title: language.t("command.message.next"),
description: language.t("command.message.next.description"),
title: input.language.t("command.message.next"),
description: input.language.t("command.message.next.description"),
keybind: "mod+arrowdown",
disabled: !params.id,
onSelect: () => args.navigateMessageByOffset(1),
disabled: !input.params.id,
onSelect: () => input.navigateMessageByOffset(1),
}),
])
const agentCommands = createMemo(() => [
modelCommand({
id: "model.choose",
title: language.t("command.model.choose"),
description: language.t("command.model.choose.description"),
title: input.language.t("command.model.choose"),
description: input.language.t("command.model.choose.description"),
keybind: "mod+'",
slash: "model",
onSelect: () => dialog.show(() => <DialogSelectModel />),
onSelect: () => input.dialog.show(() => <DialogSelectModel />),
}),
mcpCommand({
id: "mcp.toggle",
title: language.t("command.mcp.toggle"),
description: language.t("command.mcp.toggle.description"),
title: input.language.t("command.mcp.toggle"),
description: input.language.t("command.mcp.toggle.description"),
keybind: "mod+;",
slash: "mcp",
onSelect: () => dialog.show(() => <DialogSelectMcp />),
onSelect: () => input.dialog.show(() => <DialogSelectMcp />),
}),
agentCommand({
id: "agent.cycle",
title: language.t("command.agent.cycle"),
description: language.t("command.agent.cycle.description"),
title: input.language.t("command.agent.cycle"),
description: input.language.t("command.agent.cycle.description"),
keybind: "mod+.",
slash: "agent",
onSelect: () => local.agent.move(1),
onSelect: () => input.local.agent.move(1),
}),
agentCommand({
id: "agent.cycle.reverse",
title: language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"),
title: input.language.t("command.agent.cycle.reverse"),
description: input.language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.",
onSelect: () => local.agent.move(-1),
onSelect: () => input.local.agent.move(-1),
}),
modelCommand({
id: "model.variant.cycle",
title: language.t("command.model.variant.cycle"),
description: language.t("command.model.variant.cycle.description"),
title: input.language.t("command.model.variant.cycle"),
description: input.language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d",
onSelect: () => {
local.model.variant.cycle()
input.local.model.variant.cycle()
},
}),
])
@@ -271,22 +234,22 @@ export const useSessionCommands = (args: SessionCommandContext) => {
permissionsCommand({
id: "permissions.autoaccept",
title:
params.id && permission.isAutoAccepting(params.id, sdk.directory)
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"),
input.params.id && input.permission.isAutoAccepting(input.params.id, input.sdk.directory)
? input.language.t("command.permissions.autoaccept.disable")
: input.language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a",
disabled: !params.id || !permission.permissionsEnabled(),
disabled: !input.params.id || !input.permission.permissionsEnabled(),
onSelect: () => {
const sessionID = params.id
const sessionID = input.params.id
if (!sessionID) return
permission.toggleAutoAccept(sessionID, sdk.directory)
input.permission.toggleAutoAccept(sessionID, input.sdk.directory)
showToast({
title: permission.isAutoAccepting(sessionID, sdk.directory)
? language.t("toast.permissions.autoaccept.on.title")
: language.t("toast.permissions.autoaccept.off.title"),
description: permission.isAutoAccepting(sessionID, sdk.directory)
? language.t("toast.permissions.autoaccept.on.description")
: language.t("toast.permissions.autoaccept.off.description"),
title: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
? input.language.t("toast.permissions.autoaccept.on.title")
: input.language.t("toast.permissions.autoaccept.off.title"),
description: input.permission.isAutoAccepting(sessionID, input.sdk.directory)
? input.language.t("toast.permissions.autoaccept.on.description")
: input.language.t("toast.permissions.autoaccept.off.description"),
})
},
}),
@@ -295,71 +258,71 @@ export const useSessionCommands = (args: SessionCommandContext) => {
const sessionActionCommands = createMemo(() => [
sessionCommand({
id: "session.undo",
title: language.t("command.session.undo"),
description: language.t("command.session.undo.description"),
title: input.language.t("command.session.undo"),
description: input.language.t("command.session.undo.description"),
slash: "undo",
disabled: !params.id || visibleUserMessages().length === 0,
disabled: !input.params.id || input.visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = params.id
const sessionID = input.params.id
if (!sessionID) return
if (status()?.type !== "idle") {
await sdk.client.session.abort({ sessionID }).catch(() => {})
if (input.status()?.type !== "idle") {
await input.sdk.client.session.abort({ sessionID }).catch(() => {})
}
const revert = info()?.revert?.messageID
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
const revert = input.info()?.revert?.messageID
const message = findLast(input.userMessages(), (x) => !revert || x.id < revert)
if (!message) return
await sdk.client.session.revert({ sessionID, messageID: message.id })
const parts = sync.data.part[message.id]
await input.sdk.client.session.revert({ sessionID, messageID: message.id })
const parts = input.sync.data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
prompt.set(restored)
const restored = extractPromptFromParts(parts, { directory: input.sdk.directory })
input.prompt.set(restored)
}
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
args.setActiveMessage(priorMessage)
const priorMessage = findLast(input.userMessages(), (x) => x.id < message.id)
input.setActiveMessage(priorMessage)
},
}),
sessionCommand({
id: "session.redo",
title: language.t("command.session.redo"),
description: language.t("command.session.redo.description"),
title: input.language.t("command.session.redo"),
description: input.language.t("command.session.redo.description"),
slash: "redo",
disabled: !params.id || !info()?.revert?.messageID,
disabled: !input.params.id || !input.info()?.revert?.messageID,
onSelect: async () => {
const sessionID = params.id
const sessionID = input.params.id
if (!sessionID) return
const revertMessageID = info()?.revert?.messageID
const revertMessageID = input.info()?.revert?.messageID
if (!revertMessageID) return
const nextMessage = userMessages().find((x) => x.id > revertMessageID)
const nextMessage = input.userMessages().find((x) => x.id > revertMessageID)
if (!nextMessage) {
await sdk.client.session.unrevert({ sessionID })
prompt.reset()
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
args.setActiveMessage(lastMsg)
await input.sdk.client.session.unrevert({ sessionID })
input.prompt.reset()
const lastMsg = findLast(input.userMessages(), (x) => x.id >= revertMessageID)
input.setActiveMessage(lastMsg)
return
}
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
args.setActiveMessage(priorMsg)
await input.sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
const priorMsg = findLast(input.userMessages(), (x) => x.id < nextMessage.id)
input.setActiveMessage(priorMsg)
},
}),
sessionCommand({
id: "session.compact",
title: language.t("command.session.compact"),
description: language.t("command.session.compact.description"),
title: input.language.t("command.session.compact"),
description: input.language.t("command.session.compact.description"),
slash: "compact",
disabled: !params.id || visibleUserMessages().length === 0,
disabled: !input.params.id || input.visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = params.id
const sessionID = input.params.id
if (!sessionID) return
const model = local.model.current()
const model = input.local.model.current()
if (!model) {
showToast({
title: language.t("toast.model.none.title"),
description: language.t("toast.model.none.description"),
title: input.language.t("toast.model.none.title"),
description: input.language.t("toast.model.none.description"),
})
return
}
await sdk.client.session.summarize({
await input.sdk.client.session.summarize({
sessionID,
modelID: model.id,
providerID: model.provider.id,
@@ -368,27 +331,29 @@ export const useSessionCommands = (args: SessionCommandContext) => {
}),
sessionCommand({
id: "session.fork",
title: language.t("command.session.fork"),
description: language.t("command.session.fork.description"),
title: input.language.t("command.session.fork"),
description: input.language.t("command.session.fork.description"),
slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => dialog.show(() => <DialogFork />),
disabled: !input.params.id || input.visibleUserMessages().length === 0,
onSelect: () => input.dialog.show(() => <DialogFork />),
}),
])
const shareCommands = createMemo(() => {
if (sync.data.config.share === "disabled") return []
if (input.sync.data.config.share === "disabled") return []
return [
sessionCommand({
id: "session.share",
title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"),
description: info()?.share?.url
? language.t("toast.session.share.success.description")
: language.t("command.session.share.description"),
title: input.info()?.share?.url
? input.language.t("session.share.copy.copyLink")
: input.language.t("command.session.share"),
description: input.info()?.share?.url
? input.language.t("toast.session.share.success.description")
: input.language.t("command.session.share.description"),
slash: "share",
disabled: !params.id,
disabled: !input.params.id,
onSelect: async () => {
if (!params.id) return
if (!input.params.id) return
const write = (value: string) => {
const body = typeof document === "undefined" ? undefined : document.body
@@ -418,7 +383,7 @@ export const useSessionCommands = (args: SessionCommandContext) => {
const ok = await write(url)
if (!ok) {
showToast({
title: language.t("toast.session.share.copyFailed.title"),
title: input.language.t("toast.session.share.copyFailed.title"),
variant: "error",
})
return
@@ -426,27 +391,27 @@ export const useSessionCommands = (args: SessionCommandContext) => {
showToast({
title: existing
? language.t("session.share.copy.copied")
: language.t("toast.session.share.success.title"),
description: language.t("toast.session.share.success.description"),
? input.language.t("session.share.copy.copied")
: input.language.t("toast.session.share.success.title"),
description: input.language.t("toast.session.share.success.description"),
variant: "success",
})
}
const existing = info()?.share?.url
const existing = input.info()?.share?.url
if (existing) {
await copy(existing, true)
return
}
const url = await sdk.client.session
.share({ sessionID: params.id })
const url = await input.sdk.client.session
.share({ sessionID: input.params.id })
.then((res) => res.data?.share?.url)
.catch(() => undefined)
if (!url) {
showToast({
title: language.t("toast.session.share.failed.title"),
description: language.t("toast.session.share.failed.description"),
title: input.language.t("toast.session.share.failed.title"),
description: input.language.t("toast.session.share.failed.description"),
variant: "error",
})
return
@@ -457,25 +422,25 @@ export const useSessionCommands = (args: SessionCommandContext) => {
}),
sessionCommand({
id: "session.unshare",
title: language.t("command.session.unshare"),
description: language.t("command.session.unshare.description"),
title: input.language.t("command.session.unshare"),
description: input.language.t("command.session.unshare.description"),
slash: "unshare",
disabled: !params.id || !info()?.share?.url,
disabled: !input.params.id || !input.info()?.share?.url,
onSelect: async () => {
if (!params.id) return
await sdk.client.session
.unshare({ sessionID: params.id })
if (!input.params.id) return
await input.sdk.client.session
.unshare({ sessionID: input.params.id })
.then(() =>
showToast({
title: language.t("toast.session.unshare.success.title"),
description: language.t("toast.session.unshare.success.description"),
title: input.language.t("toast.session.unshare.success.title"),
description: input.language.t("toast.session.unshare.success.description"),
variant: "success",
}),
)
.catch(() =>
showToast({
title: language.t("toast.session.unshare.failed.title"),
description: language.t("toast.session.unshare.failed.description"),
title: input.language.t("toast.session.unshare.failed.title"),
description: input.language.t("toast.session.unshare.failed.description"),
variant: "error",
}),
)
@@ -484,8 +449,8 @@ export const useSessionCommands = (args: SessionCommandContext) => {
]
})
command.register("session", () =>
[
input.command.register("session", () =>
combineCommandSections([
sessionCommands(),
fileCommands(),
contextCommands(),
@@ -495,6 +460,6 @@ export const useSessionCommands = (args: SessionCommandContext) => {
permissionCommands(),
sessionActionCommands(),
shareCommands(),
].flatMap((section) => section),
]),
)
}

View File

@@ -1,26 +0,0 @@
import { describe, expect, test } from "bun:test"
import { handleNotificationClick } from "./notification-click"
describe("notification click", () => {
test("focuses and navigates when href exists", () => {
const calls: string[] = []
handleNotificationClick("/abc/session/123", {
focus: () => calls.push("focus"),
location: {
assign: (href) => calls.push(href),
},
})
expect(calls).toEqual(["focus", "/abc/session/123"])
})
test("only focuses when href is missing", () => {
const calls: string[] = []
handleNotificationClick(undefined, {
focus: () => calls.push("focus"),
location: {
assign: (href) => calls.push(href),
},
})
expect(calls).toEqual(["focus"])
})
})

View File

@@ -1,12 +0,0 @@
type WindowTarget = {
focus: () => void
location: {
assign: (href: string) => void
}
}
export const handleNotificationClick = (href?: string, target: WindowTarget = window) => {
target.focus()
if (!href) return
target.location.assign(href)
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.2.6",
"version": "1.1.64",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -110,7 +110,6 @@ export async function handler(
providerInfo.modifyBody({
...createBodyConverter(opts.format, providerInfo.format)(body),
model: providerInfo.model,
...(providerInfo.payloadModifier ?? {}),
}),
)
logger.debug("REQUEST URL: " + reqUrl)
@@ -275,8 +274,8 @@ export async function handler(
part = part.trim()
usageParser.parse(part)
if (providerInfo.responseModifier) {
for (const [k, v] of Object.entries(providerInfo.responseModifier)) {
if (providerInfo.bodyModifier) {
for (const [k, v] of Object.entries(providerInfo.bodyModifier)) {
part = part.replace(k, v)
}
c.enqueue(encoder.encode(part + "\n\n"))
@@ -286,7 +285,7 @@ export async function handler(
}
}
if (!providerInfo.responseModifier && providerInfo.format === opts.format) {
if (!providerInfo.bodyModifier && providerInfo.format === opts.format) {
c.enqueue(value)
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.2.6",
"version": "1.1.64",
"private": true,
"type": "module",
"license": "MIT",
@@ -12,7 +12,7 @@
"@opencode-ai/console-resource": "workspace:*",
"@planetscale/database": "1.19.0",
"aws4fetch": "1.0.20",
"drizzle-orm": "catalog:",
"drizzle-orm": "0.41.0",
"postgres": "3.4.7",
"stripe": "18.0.0",
"ulid": "catalog:",
@@ -44,7 +44,7 @@
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.0",
"@types/node": "catalog:",
"drizzle-kit": "catalog:",
"drizzle-kit": "0.30.5",
"mysql2": "3.14.4",
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"

View File

@@ -4,6 +4,7 @@ export * from "drizzle-orm"
import { Client } from "@planetscale/database"
import { MySqlTransaction, type MySqlTransactionConfig } from "drizzle-orm/mysql-core"
import type { ExtractTablesWithRelations } from "drizzle-orm"
import type { PlanetScalePreparedQueryHKT, PlanetscaleQueryResultHKT } from "drizzle-orm/planetscale-serverless"
import { Context } from "../context"
import { memo } from "../util/memo"
@@ -13,7 +14,7 @@ export namespace Database {
PlanetscaleQueryResultHKT,
PlanetScalePreparedQueryHKT,
Record<string, never>,
any
ExtractTablesWithRelations<Record<string, never>>
>
const client = memo(() => {
@@ -22,7 +23,7 @@ export namespace Database {
username: Resource.Database.username,
password: Resource.Database.password,
})
const db = drizzle({ client: result })
const db = drizzle(result, {})
return db
})

View File

@@ -62,13 +62,12 @@ export namespace ZenData {
apiKey: z.string(),
format: FormatSchema.optional(),
headerMappings: z.record(z.string(), z.string()).optional(),
payloadModifier: z.record(z.string(), z.any()).optional(),
family: z.string().optional(),
})
const ProviderFamilySchema = z.object({
headers: z.record(z.string(), z.string()).optional(),
responseModifier: z.record(z.string(), z.string()).optional(),
bodyModifier: z.record(z.string(), z.string()).optional(),
})
const ModelsSchema = z.object({

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.2.6",
"version": "1.1.64",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.2.6",
"version": "1.1.64",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.2.6",
"version": "1.1.64",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -2343,9 +2343,9 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.180"
version = "0.2.177"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
[[package]]
name = "libloading"
@@ -2663,18 +2663,6 @@ dependencies = [
"memoffset",
]
[[package]]
name = "nix"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "225e7cfe711e0ba79a68baeddb2982723e4235247aefce1482f2f16c27865b66"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nodrop"
version = "0.1.14"
@@ -3105,7 +3093,6 @@ dependencies = [
"listeners",
"objc2 0.6.3",
"objc2-web-kit",
"process-wrap",
"reqwest 0.12.24",
"semver",
"serde",
@@ -3136,6 +3123,7 @@ dependencies = [
"tracing-subscriber",
"uuid",
"webkit2gtk",
"windows 0.61.3",
]
[[package]]
@@ -3650,20 +3638,6 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "process-wrap"
version = "9.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccd9713fe2c91c3c85ac388b31b89de339365d2c995146e630b5e0da9d06526a"
dependencies = [
"futures",
"indexmap 2.12.1",
"nix 0.31.1",
"tokio",
"tracing",
"windows 0.62.2",
]
[[package]]
name = "psl-types"
version = "2.0.11"
@@ -6486,23 +6460,11 @@ version = "0.61.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
dependencies = [
"windows-collections 0.2.0",
"windows-collections",
"windows-core 0.61.2",
"windows-future 0.2.1",
"windows-future",
"windows-link 0.1.3",
"windows-numerics 0.2.0",
]
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections 0.3.2",
"windows-core 0.62.2",
"windows-future 0.3.2",
"windows-numerics 0.3.1",
"windows-numerics",
]
[[package]]
@@ -6514,15 +6476,6 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core 0.62.2",
]
[[package]]
name = "windows-core"
version = "0.51.1"
@@ -6566,18 +6519,7 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
dependencies = [
"windows-core 0.61.2",
"windows-link 0.1.3",
"windows-threading 0.1.0",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
"windows-threading 0.2.1",
"windows-threading",
]
[[package]]
@@ -6624,16 +6566,6 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core 0.62.2",
"windows-link 0.2.1",
]
[[package]]
name = "windows-registry"
version = "0.5.3"
@@ -6809,15 +6741,6 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-version"
version = "0.1.7"

View File

@@ -34,7 +34,7 @@ tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1.48.0", features = ["process"] }
tokio = "1.48.0"
listeners = "0.3"
tauri-plugin-os = "2"
futures = "0.3.31"
@@ -52,7 +52,6 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-appender = "0.2"
chrono = "0.4"
tokio-stream = { version = "0.1.18", features = ["sync"] }
process-wrap = { version = "9.0.3", features = ["tokio1"] }
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18.2"
@@ -63,6 +62,14 @@ objc2 = "0.6"
objc2-web-kit = "0.3"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.61", features = [
"Win32_Foundation",
"Win32_System_JobObjects",
"Win32_System_Threading",
"Win32_Security"
] }
[patch.crates-io]
specta = { git = "https://github.com/specta-rs/specta", rev = "591a5f3ddc78348abf4cbb541d599d65306d92b9" }
specta-typescript = { git = "https://github.com/specta-rs/specta", rev = "591a5f3ddc78348abf4cbb541d599d65306d92b9" }

View File

@@ -1,3 +1,10 @@
fn main() {
if let Ok(git_ref) = std::env::var("GITHUB_REF") {
let branch = git_ref.strip_prefix("refs/heads/").unwrap_or(&git_ref);
if branch == "beta" {
println!("cargo:rustc-env=OPENCODE_SQLITE=1");
}
}
tauri_build::build()
}

View File

@@ -1,19 +1,12 @@
use futures::{FutureExt, Stream, StreamExt, future};
use process_wrap::tokio::CommandWrap;
#[cfg(unix)]
use process_wrap::tokio::ProcessGroup;
#[cfg(windows)]
use process_wrap::tokio::{JobObject, KillOnDrop};
#[cfg(unix)]
use std::os::unix::process::ExitStatusExt;
use std::{process::Stdio, time::Duration};
use tauri::{AppHandle, Manager, path::BaseDirectory};
use tauri_plugin_shell::{
ShellExt,
process::{CommandChild, CommandEvent, TerminatedPayload},
};
use tauri_plugin_store::StoreExt;
use tauri_specta::Event;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::{mpsc, oneshot};
use tokio_stream::wrappers::ReceiverStream;
use tokio::sync::oneshot;
use tracing::Instrument;
use crate::constants::{SETTINGS_STORE, WSL_ENABLED_KEY};
@@ -32,33 +25,6 @@ pub struct Config {
pub server: Option<ServerConfig>,
}
#[derive(Clone, Debug)]
pub enum CommandEvent {
Stdout(Vec<u8>),
Stderr(Vec<u8>),
Error(String),
Terminated(TerminatedPayload),
}
#[derive(Clone, Copy, Debug)]
pub struct TerminatedPayload {
pub code: Option<i32>,
pub signal: Option<i32>,
}
#[derive(Clone, Debug)]
pub struct CommandChild {
kill: mpsc::Sender<()>,
}
impl CommandChild {
pub fn kill(&self) -> std::io::Result<()> {
self.kill
.try_send(())
.map_err(|e| std::io::Error::other(e.to_string()))
}
}
pub async fn get_config(app: &AppHandle) -> Option<Config> {
let (events, _) = spawn_command(app, "debug config", &[]).ok()?;
@@ -224,7 +190,7 @@ pub fn spawn_command(
app: &tauri::AppHandle,
args: &str,
extra_env: &[(&str, String)],
) -> Result<(impl Stream<Item = CommandEvent> + 'static, CommandChild), std::io::Error> {
) -> Result<(impl Stream<Item = CommandEvent> + 'static, CommandChild), tauri_plugin_shell::Error> {
let state_dir = app
.path()
.resolve("", BaseDirectory::AppLocalData)
@@ -251,7 +217,7 @@ pub fn spawn_command(
.map(|(key, value)| (key.to_string(), value.clone())),
);
let mut cmd = if cfg!(windows) {
let cmd = if cfg!(windows) {
if is_wsl_enabled(app) {
tracing::info!("WSL is enabled, spawning CLI server in WSL");
let version = app.package_info().version.to_string();
@@ -283,16 +249,18 @@ pub fn spawn_command(
script.push(format!("{} exec \"$BIN\" {}", env_prefix.join(" "), args));
let mut cmd = Command::new("wsl");
cmd.args(["-e", "bash", "-lc", &script.join("\n")]);
cmd
app.shell()
.command("wsl")
.args(["-e", "bash", "-lc", &script.join("\n")])
} else {
let sidecar = get_sidecar_path(app);
let mut cmd = Command::new(sidecar);
cmd.args(args.split_whitespace());
let mut cmd = app
.shell()
.sidecar("opencode-cli")
.unwrap()
.args(args.split_whitespace());
for (key, value) in envs {
cmd.env(key, value);
cmd = cmd.env(key, value);
}
cmd
@@ -301,111 +269,26 @@ pub fn spawn_command(
let sidecar = get_sidecar_path(app);
let shell = get_user_shell();
let line = if shell.ends_with("/nu") {
let cmd = if shell.ends_with("/nu") {
format!("^\"{}\" {}", sidecar.display(), args)
} else {
format!("\"{}\" {}", sidecar.display(), args)
};
let mut cmd = Command::new(shell);
cmd.args(["-il", "-c", &line]);
let mut cmd = app.shell().command(&shell).args(["-il", "-c", &cmd]);
for (key, value) in envs {
cmd.env(key, value);
cmd = cmd.env(key, value);
}
cmd
};
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut wrap = CommandWrap::from(cmd);
#[cfg(unix)]
{
wrap.wrap(ProcessGroup::leader());
}
#[cfg(windows)]
{
wrap.wrap(JobObject).wrap(KillOnDrop);
}
let mut child = wrap.spawn()?;
let stdout = child.stdout().take();
let stderr = child.stderr().take();
let (tx, rx) = mpsc::channel(256);
let (kill_tx, mut kill_rx) = mpsc::channel(1);
if let Some(stdout) = stdout {
let tx = tx.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(stdout).lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = tx.send(CommandEvent::Stdout(line.into_bytes())).await;
}
});
}
if let Some(stderr) = stderr {
let tx = tx.clone();
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = tx.send(CommandEvent::Stderr(line.into_bytes())).await;
}
});
}
tokio::spawn(async move {
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break Ok(status),
Ok(None) => {}
Err(err) => break Err(err),
}
tokio::select! {
_ = kill_rx.recv() => {
let _ = child.start_kill();
}
_ = tokio::time::sleep(Duration::from_millis(100)) => {}
}
};
match status {
Ok(status) => {
let payload = TerminatedPayload {
code: status.code(),
signal: signal_from_status(status),
};
let _ = tx.send(CommandEvent::Terminated(payload)).await;
}
Err(err) => {
let _ = tx.send(CommandEvent::Error(err.to_string())).await;
}
}
});
let event_stream = ReceiverStream::new(rx);
let (rx, child) = cmd.spawn()?;
let event_stream = tokio_stream::wrappers::ReceiverStream::new(rx);
let event_stream = sqlite_migration::logs_middleware(app.clone(), event_stream);
Ok((event_stream, CommandChild { kill: kill_tx }))
}
fn signal_from_status(status: std::process::ExitStatus) -> Option<i32> {
#[cfg(unix)]
{
return status.signal();
}
#[cfg(not(unix))]
{
let _ = status;
None
}
Ok((event_stream, child))
}
pub fn serve(
@@ -457,6 +340,7 @@ pub fn serve(
let _ = tx.send(payload);
}
}
_ => {}
}
future::ready(())

View File

@@ -0,0 +1,145 @@
//! Windows Job Object for reliable child process cleanup.
//!
//! This module provides a wrapper around Windows Job Objects with the
//! `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` flag set. When the job object handle
//! is closed (including when the parent process exits or crashes), Windows
//! automatically terminates all processes assigned to the job.
//!
//! This is more reliable than manual cleanup because it works even if:
//! - The parent process crashes
//! - The parent is killed via Task Manager
//! - The RunEvent::Exit handler fails to run
use std::io::{Error, Result};
#[cfg(windows)]
use std::sync::Mutex;
use windows::Win32::Foundation::{CloseHandle, HANDLE};
use windows::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
};
use windows::Win32::System::Threading::{OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE};
/// A Windows Job Object configured to kill all assigned processes when closed.
///
/// When this struct is dropped or when the owning process exits (even abnormally),
/// Windows will automatically terminate all processes that have been assigned to it.
pub struct JobObject(HANDLE);
// SAFETY: HANDLE is just a pointer-sized value, and Windows job objects
// can be safely accessed from multiple threads.
unsafe impl Send for JobObject {}
unsafe impl Sync for JobObject {}
impl JobObject {
/// Creates a new anonymous job object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` set.
///
/// When the last handle to this job is closed (including on process exit),
/// Windows will terminate all processes assigned to the job.
pub fn new() -> Result<Self> {
unsafe {
// Create an anonymous job object
let job = CreateJobObjectW(None, None).map_err(|e| Error::other(e.message()))?;
// Configure the job to kill all processes when the handle is closed
let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
SetInformationJobObject(
job,
JobObjectExtendedLimitInformation,
&info as *const _ as *const std::ffi::c_void,
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
)
.map_err(|e| Error::other(e.message()))?;
Ok(Self(job))
}
}
/// Assigns a process to this job object by its process ID.
///
/// Once assigned, the process will be terminated when this job object is dropped
/// or when the owning process exits.
///
/// # Arguments
/// * `pid` - The process ID of the process to assign
pub fn assign_pid(&self, pid: u32) -> Result<()> {
unsafe {
// Open a handle to the process with the minimum required permissions
// PROCESS_SET_QUOTA and PROCESS_TERMINATE are required by AssignProcessToJobObject
let process = OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, false, pid)
.map_err(|e| Error::other(e.message()))?;
// Assign the process to the job
let result = AssignProcessToJobObject(self.0, process);
// Close our handle to the process - the job object maintains its own reference
let _ = CloseHandle(process);
result.map_err(|e| Error::other(e.message()))
}
}
}
impl Drop for JobObject {
fn drop(&mut self) {
unsafe {
// When this handle is closed and it's the last handle to the job,
// Windows will terminate all processes in the job due to KILL_ON_JOB_CLOSE
let _ = CloseHandle(self.0);
}
}
}
/// Holds the Windows Job Object that ensures child processes are killed when the app exits.
/// On Windows, when the job object handle is closed (including on crash), all assigned
/// processes are automatically terminated by the OS.
#[cfg(windows)]
pub struct JobObjectState {
job: Mutex<Option<JobObject>>,
error: Mutex<Option<String>>,
}
#[cfg(windows)]
impl JobObjectState {
pub fn new() -> Self {
match JobObject::new() {
Ok(job) => Self {
job: Mutex::new(Some(job)),
error: Mutex::new(None),
},
Err(e) => {
tracing::error!("Failed to create job object: {e}");
Self {
job: Mutex::new(None),
error: Mutex::new(Some(format!("Failed to create job object: {e}"))),
}
}
}
}
pub fn assign_pid(&self, pid: u32) {
if let Some(job) = self.job.lock().unwrap().as_ref() {
if let Err(e) = job.assign_pid(pid) {
tracing::error!(pid, "Failed to assign process to job object: {e}");
*self.error.lock().unwrap() =
Some(format!("Failed to assign process to job object: {e}"));
} else {
tracing::info!(pid, "Assigned process to job object for automatic cleanup");
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_job_object_creation() {
let job = JobObject::new();
assert!(job.is_ok(), "Failed to create job object: {:?}", job.err());
}
}

View File

@@ -1,20 +1,21 @@
mod cli;
mod constants;
#[cfg(windows)]
mod job_object;
#[cfg(target_os = "linux")]
pub mod linux_display;
#[cfg(target_os = "linux")]
pub mod linux_windowing;
mod logging;
mod markdown;
mod server;
mod window_customizer;
mod windows;
use crate::cli::CommandChild;
use futures::{
FutureExt, TryFutureExt,
future::{self, Shared},
};
#[cfg(windows)]
use job_object::*;
use std::{
env,
net::TcpListener,
@@ -26,6 +27,7 @@ use std::{
use tauri::{AppHandle, Listener, Manager, RunEvent, State, ipc::Channel};
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_shell::process::CommandChild;
use tauri_specta::Event;
use tokio::{
sync::{oneshot, watch},
@@ -564,8 +566,8 @@ async fn initialize(app: AppHandle) {
// come from any invocation of the sidecar CLI. The progress is captured by a stdout stream interceptor.
// Then in the loading task, we wait for sqlite migration to complete before
// starting our health check against the server, otherwise long migrations could result in a timeout.
let needs_sqlite_migration = !sqlite_file_exists();
let sqlite_done = needs_sqlite_migration.then(|| {
let sqlite_enabled = option_env!("OPENCODE_SQLITE").is_some();
let sqlite_done = (sqlite_enabled && !sqlite_file_exists()).then(|| {
tracing::info!(
path = %opencode_db_path().expect("failed to get db path").display(),
"Sqlite file not found, waiting for it to be generated"
@@ -629,6 +631,12 @@ async fn initialize(app: AppHandle) {
tracing::info!("CLI health check OK");
#[cfg(windows)]
{
let job_state = app.state::<JobObjectState>();
job_state.assign_pid(child.pid());
}
app.state::<ServerState>().set_child(Some(child));
Ok(ServerReadyData { url, password })
@@ -657,14 +665,12 @@ async fn initialize(app: AppHandle) {
}
let _ = server_ready_rx.await;
tracing::info!("Loading task finished");
}
})
.map_err(|_| ())
.shared();
let loading_window = if needs_sqlite_migration
let loading_window = if sqlite_enabled
&& timeout(Duration::from_secs(1), loading_task.clone())
.await
.is_err()
@@ -702,6 +708,9 @@ fn setup_app(app: &tauri::AppHandle, init_rx: watch::Receiver<InitStep>) {
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
app.deep_link().register_all().ok();
#[cfg(windows)]
app.manage(JobObjectState::new());
app.manage(InitState { current: init_rx });
}

View File

@@ -1,475 +0,0 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Backend {
Auto,
Wayland,
X11,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendDecision {
pub backend: Backend,
pub note: String,
}
#[derive(Debug, Clone, Default)]
pub struct SessionEnv {
pub wayland_display: bool,
pub xdg_session_type: Option<String>,
pub display: bool,
pub xdg_current_desktop: Option<String>,
pub xdg_session_desktop: Option<String>,
pub desktop_session: Option<String>,
pub oc_allow_wayland: Option<String>,
pub oc_force_x11: Option<String>,
pub oc_force_wayland: Option<String>,
pub oc_linux_decorations: Option<String>,
pub oc_force_decorations: Option<String>,
pub oc_no_decorations: Option<String>,
pub i3_sock: bool,
}
impl SessionEnv {
pub fn capture() -> Self {
Self {
wayland_display: std::env::var_os("WAYLAND_DISPLAY").is_some(),
xdg_session_type: std::env::var("XDG_SESSION_TYPE").ok(),
display: std::env::var_os("DISPLAY").is_some(),
xdg_current_desktop: std::env::var("XDG_CURRENT_DESKTOP").ok(),
xdg_session_desktop: std::env::var("XDG_SESSION_DESKTOP").ok(),
desktop_session: std::env::var("DESKTOP_SESSION").ok(),
oc_allow_wayland: std::env::var("OC_ALLOW_WAYLAND").ok(),
oc_force_x11: std::env::var("OC_FORCE_X11").ok(),
oc_force_wayland: std::env::var("OC_FORCE_WAYLAND").ok(),
oc_linux_decorations: std::env::var("OC_LINUX_DECORATIONS").ok(),
oc_force_decorations: std::env::var("OC_FORCE_DECORATIONS").ok(),
oc_no_decorations: std::env::var("OC_NO_DECORATIONS").ok(),
i3_sock: std::env::var_os("I3SOCK").is_some(),
}
}
}
pub fn select_backend(env: &SessionEnv, prefer_wayland: bool) -> Option<BackendDecision> {
if is_truthy(env.oc_force_x11.as_deref()) {
return Some(BackendDecision {
backend: Backend::X11,
note: "Forcing X11 due to OC_FORCE_X11=1".into(),
});
}
if is_truthy(env.oc_force_wayland.as_deref()) {
return Some(BackendDecision {
backend: Backend::Wayland,
note: "Forcing native Wayland due to OC_FORCE_WAYLAND=1".into(),
});
}
if !is_wayland_session(env) {
return None;
}
if prefer_wayland {
return Some(BackendDecision {
backend: Backend::Wayland,
note: "Wayland session detected; forcing native Wayland from settings".into(),
});
}
if is_truthy(env.oc_allow_wayland.as_deref()) {
return Some(BackendDecision {
backend: Backend::Wayland,
note: "Wayland session detected; forcing native Wayland due to OC_ALLOW_WAYLAND=1"
.into(),
});
}
Some(BackendDecision {
backend: Backend::Auto,
note: "Wayland session detected; using native Wayland first with X11 fallback (auto backend). Set OC_FORCE_X11=1 to force X11."
.into(),
})
}
pub fn use_decorations(env: &SessionEnv) -> bool {
if let Some(mode) = decoration_override(env.oc_linux_decorations.as_deref()) {
return match mode {
DecorationOverride::Native => true,
DecorationOverride::None => false,
DecorationOverride::Auto => default_use_decorations(env),
};
}
if is_truthy(env.oc_force_decorations.as_deref()) {
return true;
}
if is_truthy(env.oc_no_decorations.as_deref()) {
return false;
}
default_use_decorations(env)
}
fn default_use_decorations(env: &SessionEnv) -> bool {
if is_known_tiling_session(env) {
return false;
}
if !is_wayland_session(env) {
return true;
}
is_full_desktop_session(env)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DecorationOverride {
Auto,
Native,
None,
}
fn decoration_override(value: Option<&str>) -> Option<DecorationOverride> {
let value = value?.trim().to_ascii_lowercase();
if matches!(value.as_str(), "auto") {
return Some(DecorationOverride::Auto);
}
if matches!(
value.as_str(),
"native" | "server" | "de" | "wayland" | "on" | "true" | "1"
) {
return Some(DecorationOverride::Native);
}
if matches!(
value.as_str(),
"none" | "off" | "false" | "0" | "client" | "csd"
) {
return Some(DecorationOverride::None);
}
None
}
fn is_truthy(value: Option<&str>) -> bool {
matches!(
value.map(|v| v.trim().to_ascii_lowercase()),
Some(v) if matches!(v.as_str(), "1" | "true" | "yes" | "on")
)
}
fn is_wayland_session(env: &SessionEnv) -> bool {
env.wayland_display
|| matches!(
env.xdg_session_type.as_deref(),
Some(value) if value.eq_ignore_ascii_case("wayland")
)
}
fn is_full_desktop_session(env: &SessionEnv) -> bool {
desktop_tokens(env).any(|value| {
matches!(
value.as_str(),
"gnome"
| "kde"
| "plasma"
| "xfce"
| "xfce4"
| "x-cinnamon"
| "cinnamon"
| "mate"
| "lxqt"
| "budgie"
| "pantheon"
| "deepin"
| "unity"
| "cosmic"
)
})
}
fn is_known_tiling_session(env: &SessionEnv) -> bool {
if env.i3_sock {
return true;
}
desktop_tokens(env).any(|value| {
matches!(
value.as_str(),
"niri"
| "sway"
| "swayfx"
| "hyprland"
| "river"
| "i3"
| "i3wm"
| "bspwm"
| "dwm"
| "qtile"
| "xmonad"
| "leftwm"
| "dwl"
| "awesome"
| "herbstluftwm"
| "spectrwm"
| "worm"
| "i3-gnome"
)
})
}
fn desktop_tokens<'a>(env: &'a SessionEnv) -> impl Iterator<Item = String> + 'a {
[
env.xdg_current_desktop.as_deref(),
env.xdg_session_desktop.as_deref(),
env.desktop_session.as_deref(),
]
.into_iter()
.flatten()
.flat_map(|desktop| desktop.split(':'))
.map(|value| value.trim().to_ascii_lowercase())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prefers_wayland_first_on_wayland_session() {
let env = SessionEnv {
wayland_display: true,
display: true,
..Default::default()
};
let decision = select_backend(&env, false).expect("missing decision");
assert_eq!(decision.backend, Backend::Auto);
}
#[test]
fn force_x11_override_wins() {
let env = SessionEnv {
wayland_display: true,
display: true,
oc_force_x11: Some("1".into()),
oc_allow_wayland: Some("1".into()),
oc_force_wayland: Some("1".into()),
..Default::default()
};
let decision = select_backend(&env, true).expect("missing decision");
assert_eq!(decision.backend, Backend::X11);
}
#[test]
fn prefer_wayland_forces_wayland_backend() {
let env = SessionEnv {
wayland_display: true,
display: true,
..Default::default()
};
let decision = select_backend(&env, true).expect("missing decision");
assert_eq!(decision.backend, Backend::Wayland);
}
#[test]
fn force_wayland_override_works_outside_wayland_session() {
let env = SessionEnv {
display: true,
oc_force_wayland: Some("1".into()),
..Default::default()
};
let decision = select_backend(&env, false).expect("missing decision");
assert_eq!(decision.backend, Backend::Wayland);
}
#[test]
fn allow_wayland_forces_wayland_backend() {
let env = SessionEnv {
wayland_display: true,
display: true,
oc_allow_wayland: Some("1".into()),
..Default::default()
};
let decision = select_backend(&env, false).expect("missing decision");
assert_eq!(decision.backend, Backend::Wayland);
}
#[test]
fn xdg_session_type_wayland_is_detected() {
let env = SessionEnv {
xdg_session_type: Some("wayland".into()),
..Default::default()
};
let decision = select_backend(&env, false).expect("missing decision");
assert_eq!(decision.backend, Backend::Auto);
}
#[test]
fn returns_none_when_not_wayland_and_no_overrides() {
let env = SessionEnv {
display: true,
xdg_current_desktop: Some("GNOME".into()),
..Default::default()
};
assert!(select_backend(&env, false).is_none());
}
#[test]
fn prefer_wayland_setting_does_not_override_x11_session() {
let env = SessionEnv {
display: true,
xdg_current_desktop: Some("GNOME".into()),
..Default::default()
};
assert!(select_backend(&env, true).is_none());
}
#[test]
fn disables_decorations_on_niri() {
let env = SessionEnv {
xdg_current_desktop: Some("niri".into()),
wayland_display: true,
..Default::default()
};
assert!(!use_decorations(&env));
}
#[test]
fn keeps_decorations_on_gnome() {
let env = SessionEnv {
xdg_current_desktop: Some("GNOME".into()),
wayland_display: true,
..Default::default()
};
assert!(use_decorations(&env));
}
#[test]
fn disables_decorations_when_session_desktop_is_tiling() {
let env = SessionEnv {
xdg_session_desktop: Some("Hyprland".into()),
wayland_display: true,
..Default::default()
};
assert!(!use_decorations(&env));
}
#[test]
fn disables_decorations_for_unknown_wayland_session() {
let env = SessionEnv {
xdg_current_desktop: Some("labwc".into()),
wayland_display: true,
..Default::default()
};
assert!(!use_decorations(&env));
}
#[test]
fn disables_decorations_for_dwm_on_x11() {
let env = SessionEnv {
xdg_current_desktop: Some("dwm".into()),
display: true,
..Default::default()
};
assert!(!use_decorations(&env));
}
#[test]
fn disables_decorations_for_i3_on_x11() {
let env = SessionEnv {
xdg_current_desktop: Some("i3".into()),
display: true,
..Default::default()
};
assert!(!use_decorations(&env));
}
#[test]
fn disables_decorations_for_i3sock_without_xdg_tokens() {
let env = SessionEnv {
display: true,
i3_sock: true,
..Default::default()
};
assert!(!use_decorations(&env));
}
#[test]
fn keeps_decorations_for_gnome_on_x11() {
let env = SessionEnv {
xdg_current_desktop: Some("GNOME".into()),
display: true,
..Default::default()
};
assert!(use_decorations(&env));
}
#[test]
fn no_decorations_override_wins() {
let env = SessionEnv {
xdg_current_desktop: Some("GNOME".into()),
oc_no_decorations: Some("1".into()),
..Default::default()
};
assert!(!use_decorations(&env));
}
#[test]
fn linux_decorations_native_override_wins() {
let env = SessionEnv {
xdg_current_desktop: Some("niri".into()),
wayland_display: true,
oc_linux_decorations: Some("native".into()),
..Default::default()
};
assert!(use_decorations(&env));
}
#[test]
fn linux_decorations_none_override_wins() {
let env = SessionEnv {
xdg_current_desktop: Some("GNOME".into()),
wayland_display: true,
oc_linux_decorations: Some("none".into()),
..Default::default()
};
assert!(!use_decorations(&env));
}
#[test]
fn linux_decorations_auto_uses_default_policy() {
let env = SessionEnv {
xdg_current_desktop: Some("sway".into()),
wayland_display: true,
oc_linux_decorations: Some("auto".into()),
..Default::default()
};
assert!(!use_decorations(&env));
}
#[test]
fn linux_decorations_override_beats_legacy_overrides() {
let env = SessionEnv {
xdg_current_desktop: Some("GNOME".into()),
wayland_display: true,
oc_linux_decorations: Some("none".into()),
oc_force_decorations: Some("1".into()),
..Default::default()
};
assert!(!use_decorations(&env));
}
}

View File

@@ -36,7 +36,11 @@ pub fn init(log_dir: &Path) -> WorkerGuard {
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer().with_writer(std::io::stderr))
.with(fmt::layer().with_writer(non_blocking).with_ansi(false))
.with(
fmt::layer()
.with_writer(non_blocking)
.with_ansi(false),
)
.init();
guard
@@ -51,7 +55,10 @@ pub fn tail() -> String {
return String::new();
};
let lines: Vec<String> = BufReader::new(file).lines().map_while(Result::ok).collect();
let lines: Vec<String> = BufReader::new(file)
.lines()
.map_while(Result::ok)
.collect();
let start = lines.len().saturating_sub(TAIL_LINES);
lines[start..].join("\n")

View File

@@ -4,7 +4,6 @@
// borrowed from https://github.com/skyline69/balatro-mod-manager
#[cfg(target_os = "linux")]
fn configure_display_backend() -> Option<String> {
use opencode_lib::linux_windowing::{Backend, SessionEnv, select_backend};
use std::env;
let set_env_if_absent = |key: &str, value: &str| {
@@ -15,28 +14,45 @@ fn configure_display_backend() -> Option<String> {
}
};
let session = SessionEnv::capture();
let prefer_wayland = opencode_lib::linux_display::read_wayland().unwrap_or(false);
let decision = select_backend(&session, prefer_wayland)?;
match decision.backend {
Backend::X11 => {
set_env_if_absent("WINIT_UNIX_BACKEND", "x11");
set_env_if_absent("GDK_BACKEND", "x11");
set_env_if_absent("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
Backend::Wayland => {
set_env_if_absent("WINIT_UNIX_BACKEND", "wayland");
set_env_if_absent("GDK_BACKEND", "wayland");
set_env_if_absent("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
Backend::Auto => {
set_env_if_absent("GDK_BACKEND", "wayland,x11");
set_env_if_absent("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
let on_wayland = env::var_os("WAYLAND_DISPLAY").is_some()
|| matches!(
env::var("XDG_SESSION_TYPE"),
Ok(v) if v.eq_ignore_ascii_case("wayland")
);
if !on_wayland {
return None;
}
Some(decision.note)
let prefer_wayland = opencode_lib::linux_display::read_wayland().unwrap_or(false);
let allow_wayland = prefer_wayland
|| matches!(
env::var("OC_ALLOW_WAYLAND"),
Ok(v) if matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes")
);
if allow_wayland {
if prefer_wayland {
return Some("Wayland session detected; using native Wayland from settings".into());
}
return Some("Wayland session detected; respecting OC_ALLOW_WAYLAND=1".into());
}
// Prefer XWayland when available to avoid Wayland protocol errors seen during startup.
if env::var_os("DISPLAY").is_some() {
set_env_if_absent("WINIT_UNIX_BACKEND", "x11");
set_env_if_absent("GDK_BACKEND", "x11");
set_env_if_absent("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
return Some(
"Wayland session detected; forcing X11 backend to avoid compositor protocol errors. \
Set OC_ALLOW_WAYLAND=1 to keep native Wayland."
.into(),
);
}
set_env_if_absent("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
Some(
"Wayland session detected without X11; leaving Wayland enabled (set WINIT_UNIX_BACKEND/GDK_BACKEND manually if needed)."
.into(),
)
}
fn main() {

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