Compare commits

..

13 Commits

Author SHA1 Message Date
Dax Raad 5a7b678553 make provider.npm and provider.api optional in model config 2026-02-12 09:03:38 -05:00
Dax Raad 8d7b0f0235 refactor: move structured output tool injection outside resolveTools 2026-02-11 23:39:52 -05:00
Dax Raad 5572602ec4 fix 2026-02-11 23:35:35 -05:00
Dax Raad a584c0fb9f Merge branch 'dev' into K-Mistele/dev 2026-02-11 23:30:11 -05:00
Kyle Mistele e5a14e6110 Merge branch 'dev' into dev 2026-01-19 15:06:07 -08:00
Kyle Mistele b1da5714d7 test: add retry behavior tests for structured output
Add 5 new unit tests that verify the retry mechanism for structured
output validation:
- Multiple validation failures trigger multiple onError calls
- Success after failures correctly calls onSuccess
- Error messages guide model to fix issues and retry
- Simulates retry state tracking matching prompt.ts logic
- Simulates successful retry after initial failures

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 11:28:29 -08:00
Kyle Mistele 4c7c65a054 merge: resolve conflicts from upstream dev
Merge upstream changes while preserving structured output feature:
- Keep tools deprecation notice from upstream
- Keep bypassAgentCheck parameter from upstream
- Keep variant field on user messages from upstream
- Preserve outputFormat and StructuredOutput tool injection

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 00:01:34 -08:00
Kyle Mistele d2beb78457 feat: structured output 2026-01-12 23:54:53 -08:00
Kyle Mistele 0e8f7694ed fix: force tool calls when outputFormat is json_schema
When structured output is requested, the model must call the
StructuredOutput tool instead of responding with plain text.

Changes:
- Add toolChoice parameter to LLM.StreamInput
- Pass toolChoice: "required" to streamText when outputFormat is json_schema
- This forces the model to call a tool, ensuring structured output is captured
2026-01-12 23:05:43 -08:00
Kyle Mistele 34f9feb12d docs: add structured output documentation to SDK reference
Document the outputFormat feature for requesting structured JSON output:
- Basic usage example with JSON schema
- Output format types (text, json_schema)
- Schema configuration options (type, schema, retryCount)
- Error handling for StructuredOutputError
- Best practices for using structured output
2026-01-12 21:04:13 -08:00
Kyle Mistele 4cb9f8ab34 fix: structured output loop exit and add system prompt
- Fix loop exit condition to check processor.message.finish instead of
  result === "stop" (processor.process() returns "continue" on normal exit)
- Add system prompt instruction when json_schema mode is enabled to ensure
  model calls the StructuredOutput tool
- Add integration tests for structured output functionality
- Fix test Session.messages call to use object parameter format
2026-01-12 20:51:17 -08:00
Kyle Mistele d582dc1c9f chore: regenerate JavaScript SDK with structured output types
Regenerate SDK types to include outputFormat and structured_output fields.
2026-01-12 20:18:50 -08:00
Kyle Mistele 32ef11da1f feat: add structured output (JSON schema) support
Add outputFormat option to session.prompt() for requesting structured JSON
output. When type is 'json_schema', injects a StructuredOutput tool that
validates model output against the provided schema.

- Add OutputFormat schema types (text, json_schema) to message-v2.ts
- Add structured_output field to AssistantMessage
- Add StructuredOutputError for validation failures
- Implement createStructuredOutputTool helper in prompt.ts
- Integrate structured output into agent loop with retry support
- Regenerate OpenAPI spec with new types
- Add unit tests for schema validation
2026-01-12 20:09:51 -08:00
405 changed files with 7857 additions and 18150 deletions
+1 -3
View File
@@ -60,11 +60,9 @@ jobs:
run: | run: |
COMMENT=$(bun script/duplicate-pr.ts -f pr_info.txt "Check the attached file for PR details and search for duplicates") COMMENT=$(bun script/duplicate-pr.ts -f pr_info.txt "Check the attached file for PR details and search for duplicates")
if [ "$COMMENT" != "No duplicate PRs found" ]; then gh pr comment "$PR_NUMBER" --body "_The following comment was made by an LLM, it may be inaccurate:_
gh pr comment "$PR_NUMBER" --body "_The following comment was made by an LLM, it may be inaccurate:_
$COMMENT" $COMMENT"
fi
add-contributor-label: add-contributor-label:
runs-on: ubuntu-latest runs-on: ubuntu-latest
-54
View File
@@ -1,54 +0,0 @@
name: sign-cli
on:
push:
branches:
- brendan/desktop-signpath
workflow_dispatch:
permissions:
contents: read
actions: read
jobs:
sign-cli:
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@v3
with:
fetch-tags: true
- uses: ./.github/actions/setup-bun
- name: Build
run: |
./packages/opencode/script/build.ts
- name: Upload unsigned Windows CLI
id: upload_unsigned_windows_cli
uses: actions/upload-artifact@v4
with:
name: unsigned-opencode-windows-cli
path: packages/opencode/dist/opencode-windows-x64/bin/opencode.exe
if-no-files-found: error
- name: Submit SignPath signing request
id: submit_signpath_signing_request
uses: signpath/github-action-submit-signing-request@v1
with:
api-token: ${{ secrets.SIGNPATH_API_KEY }}
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }}
artifact-configuration-slug: ${{ secrets.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
github-artifact-id: ${{ steps.upload_unsigned_windows_cli.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: signed-opencode-cli
- name: Upload signed Windows CLI
uses: actions/upload-artifact@v4
with:
name: signed-opencode-windows-cli
path: signed-opencode-cli/*.exe
if-no-files-found: error
-2
View File
@@ -359,7 +359,6 @@ opencode serve --hostname 0.0.0.0 --port 4096
opencode serve [--port <number>] [--hostname <string>] [--cors <origin>] opencode serve [--port <number>] [--hostname <string>] [--cors <origin>]
opencode session [command] opencode session [command]
opencode session list opencode session list
opencode session delete <sessionID>
opencode stats opencode stats
opencode uninstall opencode uninstall
opencode upgrade opencode upgrade
@@ -599,7 +598,6 @@ OPENCODE_EXPERIMENTAL_MARKDOWN
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX
OPENCODE_EXPERIMENTAL_OXFMT OPENCODE_EXPERIMENTAL_OXFMT
OPENCODE_EXPERIMENTAL_PLAN_MODE OPENCODE_EXPERIMENTAL_PLAN_MODE
OPENCODE_ENABLE_QUESTION_TOOL
OPENCODE_FAKE_VCS OPENCODE_FAKE_VCS
OPENCODE_GIT_BASH_PATH OPENCODE_GIT_BASH_PATH
OPENCODE_MODEL OPENCODE_MODEL
+3
View File
@@ -16,12 +16,15 @@ wip:
For anything in the packages/web use the docs: prefix. 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 prefer to explain WHY something was done from an end user perspective instead of
WHAT was done. WHAT was done.
do not do generic messages like "improved agent experience" be very specific do not do generic messages like "improved agent experience" be very specific
about what user facing changes were made 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 if there are conflicts DO NOT FIX THEM. notify me and I will fix them
## GIT DIFF ## GIT DIFF
+3
View File
@@ -1,5 +1,8 @@
{ {
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
// "enterprise": {
// "url": "https://enterprise.dev.opencode.ai",
// },
"provider": { "provider": {
"opencode": { "opencode": {
"options": {}, "options": {},
@@ -1,5 +0,0 @@
github-policies:
runners:
allowed_groups:
- "GitHub Actions"
- "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt"
-1
View File
@@ -110,4 +110,3 @@ const table = sqliteTable("session", {
- Avoid mocks as much as possible - Avoid mocks as much as possible
- Test actual implementation, do not duplicate logic into tests - 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`.
+2 -4
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![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 choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS و Linux (موصى به، دائما محدث) brew install anomalyco/tap/opencode # macOS و Linux (موصى به، دائما محدث)
brew install opencode # macOS و Linux (صيغة brew الرسمية، تحديث اقل) brew install opencode # macOS و Linux (صيغة brew الرسمية، تحديث اقل)
sudo pacman -S opencode # Arch Linux (Stable) paru -S opencode-bin # Arch Linux
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # اي نظام mise use -g opencode # اي نظام
nix run nixpkgs#opencode # او github:anomalyco/opencode لاحدث فرع dev nix run nixpkgs#opencode # او github:anomalyco/opencode لاحدث فرع dev
``` ```
+2 -4
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![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 choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS e Linux (recomendado, sempre atualizado) brew install anomalyco/tap/opencode # macOS e Linux (recomendado, sempre atualizado)
brew install opencode # macOS e Linux (fórmula oficial do brew, atualiza menos) 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
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # qualquer sistema mise use -g opencode # qualquer sistema
nix run nixpkgs#opencode # ou github:anomalyco/opencode para a branch dev mais recente nix run nixpkgs#opencode # ou github:anomalyco/opencode para a branch dev mais recente
``` ```
+2 -4
View File
@@ -32,8 +32,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![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 choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS i Linux (preporučeno, uvijek ažurno) 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) 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
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # Bilo koji OS mise use -g opencode # Bilo koji OS
nix run nixpkgs#opencode # ili github:anomalyco/opencode za najnoviji dev branch nix run nixpkgs#opencode # ili github:anomalyco/opencode za najnoviji dev branch
``` ```
+2 -4
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![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 choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS og Linux (anbefalet, altid up to date) 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) 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
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # alle OS mise use -g opencode # alle OS
nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch
``` ```
+2 -4
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![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 choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS und Linux (empfohlen, immer aktuell) brew install anomalyco/tap/opencode # macOS und Linux (empfohlen, immer aktuell)
brew install opencode # macOS und Linux (offizielle Brew-Formula, seltener aktualisiert) brew install opencode # macOS und Linux (offizielle Brew-Formula, seltener aktualisiert)
sudo pacman -S opencode # Arch Linux (Stable) paru -S opencode-bin # Arch Linux
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # jedes Betriebssystem mise use -g opencode # jedes Betriebssystem
nix run nixpkgs#opencode # oder github:anomalyco/opencode für den neuesten dev-Branch nix run nixpkgs#opencode # oder github:anomalyco/opencode für den neuesten dev-Branch
``` ```
+2 -4
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![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 choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS y Linux (recomendado, siempre al día) 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) 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
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # cualquier sistema mise use -g opencode # cualquier sistema
nix run nixpkgs#opencode # o github:anomalyco/opencode para la rama dev más reciente nix run nixpkgs#opencode # o github:anomalyco/opencode para la rama dev más reciente
``` ```
+2 -4
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![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 choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS et Linux (recommandé, toujours à jour) 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) 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
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # n'importe quel OS mise use -g opencode # n'importe quel OS
nix run nixpkgs#opencode # ou github:anomalyco/opencode pour la branche dev la plus récente nix run nixpkgs#opencode # ou github:anomalyco/opencode pour la branche dev la plus récente
``` ```
+2 -4
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![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 choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS e Linux (consigliato, sempre aggiornato) brew install anomalyco/tap/opencode # macOS e Linux (consigliato, sempre aggiornato)
brew install opencode # macOS e Linux (formula brew ufficiale, aggiornata meno spesso) 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
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # Qualsiasi OS mise use -g opencode # Qualsiasi OS
nix run nixpkgs#opencode # oppure github:anomalyco/opencode per lultima branch di sviluppo nix run nixpkgs#opencode # oppure github:anomalyco/opencode per lultima branch di sviluppo
``` ```
+2 -4
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![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 choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS と Linux(推奨。常に最新) brew install anomalyco/tap/opencode # macOS と Linux(推奨。常に最新)
brew install opencode # macOS と Linux(公式 brew formula。更新頻度は低め) brew install opencode # macOS と Linux(公式 brew formula。更新頻度は低め)
sudo pacman -S opencode # Arch Linux (Stable) paru -S opencode-bin # Arch Linux
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # どのOSでも mise use -g opencode # どのOSでも
nix run nixpkgs#opencode # または github:anomalyco/opencode で最新 dev ブランチ nix run nixpkgs#opencode # または github:anomalyco/opencode で最新 dev ブランチ
``` ```
+2 -4
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![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 choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 및 Linux (권장, 항상 최신) brew install anomalyco/tap/opencode # macOS 및 Linux (권장, 항상 최신)
brew install opencode # macOS 및 Linux (공식 brew formula, 업데이트 빈도 낮음) brew install opencode # macOS 및 Linux (공식 brew formula, 업데이트 빈도 낮음)
sudo pacman -S opencode # Arch Linux (Stable) paru -S opencode-bin # Arch Linux
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # 어떤 OS든 mise use -g opencode # 어떤 OS든
nix run nixpkgs#opencode # 또는 github:anomalyco/opencode 로 최신 dev 브랜치 nix run nixpkgs#opencode # 또는 github:anomalyco/opencode 로 최신 dev 브랜치
``` ```
+2 -4
View File
@@ -32,8 +32,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![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 choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date) brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
brew install opencode # macOS and Linux (official brew formula, updated less) brew install opencode # macOS and Linux (official brew formula, updated less)
sudo pacman -S opencode # Arch Linux (Stable) paru -S opencode-bin # Arch Linux
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # Any OS mise use -g opencode # Any OS
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
``` ```
+2 -4
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![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 choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS og Linux (anbefalt, alltid oppdatert) brew install anomalyco/tap/opencode # macOS og Linux (anbefalt, alltid oppdatert)
brew install opencode # macOS og Linux (offisiell brew-formel, oppdateres sjeldnere) brew install opencode # macOS og Linux (offisiell brew-formel, oppdateres sjeldnere)
sudo pacman -S opencode # Arch Linux (Stable) paru -S opencode-bin # Arch Linux
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # alle OS mise use -g opencode # alle OS
nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch
``` ```
+2 -4
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![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 choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS i Linux (polecane, zawsze aktualne) brew install anomalyco/tap/opencode # macOS i Linux (polecane, zawsze aktualne)
brew install opencode # macOS i Linux (oficjalna formuła brew, rzadziej aktualizowana) 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
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # dowolny system mise use -g opencode # dowolny system
nix run nixpkgs#opencode # lub github:anomalyco/opencode dla najnowszej gałęzi dev nix run nixpkgs#opencode # lub github:anomalyco/opencode dla najnowszej gałęzi dev
``` ```
+2 -4
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![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 choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS и Linux (рекомендуем, всегда актуально) brew install anomalyco/tap/opencode # macOS и Linux (рекомендуем, всегда актуально)
brew install opencode # macOS и Linux (официальная формула brew, обновляется реже) brew install opencode # macOS и Linux (официальная формула brew, обновляется реже)
sudo pacman -S opencode # Arch Linux (Stable) paru -S opencode-bin # Arch Linux
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # любая ОС mise use -g opencode # любая ОС
nix run nixpkgs#opencode # или github:anomalyco/opencode для самой свежей ветки dev nix run nixpkgs#opencode # или github:anomalyco/opencode для самой свежей ветки dev
``` ```
+2 -4
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![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 choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS และ Linux (แนะนำ อัปเดตเสมอ) brew install anomalyco/tap/opencode # macOS และ Linux (แนะนำ อัปเดตเสมอ)
brew install opencode # macOS และ Linux (brew formula อย่างเป็นทางการ อัปเดตน้อยกว่า) brew install opencode # macOS และ Linux (brew formula อย่างเป็นทางการ อัปเดตน้อยกว่า)
sudo pacman -S opencode # Arch Linux (Stable) paru -S opencode-bin # Arch Linux
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # ระบบปฏิบัติการใดก็ได้ mise use -g opencode # ระบบปฏิบัติการใดก็ได้
nix run nixpkgs#opencode # หรือ github:anomalyco/opencode สำหรับสาขาพัฒนาล่าสุด nix run nixpkgs#opencode # หรือ github:anomalyco/opencode สำหรับสาขาพัฒนาล่าสุด
``` ```
+2 -4
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![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 choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS ve Linux (önerilir, her zaman güncel) 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) 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
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # Tüm işletim sistemleri 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 nix run nixpkgs#opencode # veya en güncel geliştirme dalı için github:anomalyco/opencode
``` ```
-139
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)
+2 -4
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![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 choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 和 Linux(推荐,始终保持最新) brew install anomalyco/tap/opencode # macOS 和 Linux(推荐,始终保持最新)
brew install opencode # macOS 和 Linux(官方 brew formula,更新频率较低) brew install opencode # macOS 和 Linux(官方 brew formula,更新频率较低)
sudo pacman -S opencode # Arch Linux (Stable) paru -S opencode-bin # Arch Linux
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # 任意系统 mise use -g opencode # 任意系统
nix run nixpkgs#opencode # 或用 github:anomalyco/opencode 获取最新 dev 分支 nix run nixpkgs#opencode # 或用 github:anomalyco/opencode 获取最新 dev 分支
``` ```
+2 -4
View File
@@ -31,8 +31,7 @@
<a href="README.no.md">Norsk</a> | <a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> | <a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> | <a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a> | <a href="README.tr.md">Türkçe</a>
<a href="README.uk.md">Українська</a>
</p> </p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) [![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 choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 與 Linux(推薦,始終保持最新) brew install anomalyco/tap/opencode # macOS 與 Linux(推薦,始終保持最新)
brew install opencode # macOS 與 Linux(官方 brew formula,更新頻率較低) brew install opencode # macOS 與 Linux(官方 brew formula,更新頻率較低)
sudo pacman -S opencode # Arch Linux (Stable) paru -S opencode-bin # Arch Linux
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # 任何作業系統 mise use -g opencode # 任何作業系統
nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支 nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支
``` ```
+293 -355
View File
File diff suppressed because it is too large Load Diff
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1770812194, "lastModified": 1770073757,
"narHash": "sha256-OH+lkaIKAvPXR3nITO7iYZwew2nW9Y7Xxq0yfM/UcUU=", "narHash": "sha256-Vy+G+F+3E/Tl+GMNgiHl9Pah2DgShmIUBJXmbiQPHbI=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "8482c7ded03bae7550f3d69884f1e611e3bd19e8", "rev": "47472570b1e607482890801aeaf29bfb749884f6",
"type": "github" "type": "github"
}, },
"original": { "original": {
+1 -15
View File
@@ -130,7 +130,7 @@ else
needs_baseline=false needs_baseline=false
if [ "$arch" = "x64" ]; then if [ "$arch" = "x64" ]; then
if [ "$os" = "linux" ]; then if [ "$os" = "linux" ]; then
if ! grep -qwi avx2 /proc/cpuinfo 2>/dev/null; then if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
needs_baseline=true needs_baseline=true
fi fi
fi fi
@@ -141,20 +141,6 @@ else
needs_baseline=true needs_baseline=true
fi fi
fi fi
if [ "$os" = "windows" ]; then
ps="(Add-Type -MemberDefinition \"[DllImport(\"\"kernel32.dll\"\")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);\" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)"
out=""
if command -v powershell.exe >/dev/null 2>&1; then
out=$(powershell.exe -NoProfile -NonInteractive -Command "$ps" 2>/dev/null || true)
elif command -v pwsh >/dev/null 2>&1; then
out=$(pwsh -NoProfile -NonInteractive -Command "$ps" 2>/dev/null || true)
fi
out=$(echo "$out" | tr -d '\r' | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
if [ "$out" != "true" ] && [ "$out" != "1" ]; then
needs_baseline=true
fi
fi
fi fi
target="$os-$arch" target="$os-$arch"
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-C3WIEER2XgzO85wk2sp3BzQ6dknW026zslD8nKZjo2U=", "x86_64-linux": "sha256-pp2gb4nxiIT3VltB6Xli2wZPH32JfnMsI+BbihyU1+E=",
"aarch64-linux": "sha256-+tTJHZMZ/+8fAjI/1fUTuca8J2MZfB+5vhBoZ7jgqcE=", "aarch64-linux": "sha256-hJwxhBICZz/pbIxQsF/sIpZTlFIgLpcAyF44O8wxMdU=",
"aarch64-darwin": "sha256-vS82puFGBBToxyIBa8Zi0KLKdJYr64T6HZL2rL32mH8=", "aarch64-darwin": "sha256-DPONXP52XOg/ApdSnLp32a+K5XCOnDGhbTUto2Rme0g=",
"x86_64-darwin": "sha256-Tr8JMTCxV6WVt3dXV7iq3PNCm2Cn+RXAbU9+o7pKKV0=" "x86_64-darwin": "sha256-KX1h5LRJSgthpbOPqWlbM/sPf8cvQrdRJvxtrz/FzBQ="
} }
} }
+2 -7
View File
@@ -35,13 +35,11 @@
"@tsconfig/bun": "1.0.9", "@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0", "@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.1.0-beta.13", "@pierre/diffs": "1.0.2",
"@solid-primitives/storage": "4.3.3", "@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"diff": "8.0.2", "diff": "8.0.2",
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.12-a5629fb",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
"ai": "5.0.124", "ai": "5.0.124",
"hono": "4.10.7", "hono": "4.10.7",
"hono-openapi": "1.1.2", "hono-openapi": "1.1.2",
@@ -87,8 +85,6 @@
"url": "https://github.com/anomalyco/opencode" "url": "https://github.com/anomalyco/opencode"
}, },
"license": "MIT", "license": "MIT",
"randomField": "hello-world-12345",
"anotherRandomField": "xyz-abc-789",
"prettier": { "prettier": {
"semi": false, "semi": false,
"printWidth": 120 "printWidth": 120
@@ -105,7 +101,6 @@
"@types/node": "catalog:" "@types/node": "catalog:"
}, },
"patchedDependencies": { "patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@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"
} }
} }
+3 -16
View File
@@ -1,28 +1,15 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors" import { openPalette, clickListItem } from "../actions"
test("can open a file tab from the search palette", async ({ page, gotoSession }) => { test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
await gotoSession() await gotoSession()
await page.locator(promptSelector).click() const dialog = await openPalette(page)
await page.keyboard.type("/open")
const command = page.locator('[data-slash-id="file.open"]').first()
await expect(command).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first() const input = dialog.getByRole("textbox").first()
await input.fill("package.json") await input.fill("package.json")
const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first() await clickListItem(dialog, { keyStartsWith: "file:" })
await expect(item).toBeVisible({ timeout: 30_000 })
await item.click()
await expect(dialog).toHaveCount(0) await expect(dialog).toHaveCount(0)
+7 -30
View File
@@ -1,41 +1,18 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors" import { openPalette, clickListItem } from "../actions"
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => { test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
await gotoSession() await gotoSession()
await page.locator(promptSelector).click() const sep = process.platform === "win32" ? "\\" : "/"
await page.keyboard.type("/open") const file = ["packages", "app", "package.json"].join(sep)
const command = page.locator('[data-slash-id="file.open"]').first() const dialog = await openPalette(page)
await expect(command).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first() const input = dialog.getByRole("textbox").first()
await input.fill("package.json") await input.fill(file)
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]') await clickListItem(dialog, { text: /packages.*app.*package.json/ })
let index = -1
await expect
.poll(
async () => {
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
return index >= 0
},
{ timeout: 30_000 },
)
.toBe(true)
const item = items.nth(index)
await expect(item).toBeVisible()
await item.click()
await expect(dialog).toHaveCount(0) await expect(dialog).toHaveCount(0)
@@ -45,5 +22,5 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
const code = page.locator('[data-component="code"]').first() const code = page.locator('[data-component="code"]').first()
await expect(code).toBeVisible() await expect(code).toBeVisible()
await expect(code.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible() await expect(code.getByText("@opencode-ai/app")).toBeVisible()
}) })
@@ -69,19 +69,15 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible() await expect(prompt).toBeVisible()
await expect(prompt).toBeEditable()
await prompt.click() await prompt.click()
await expect(prompt).toBeFocused() await page.keyboard.type(text)
await prompt.fill(text) await page.keyboard.press("Enter")
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
await prompt.press("Enter")
await expect.poll(() => slugFromUrl(page.url())).toBe(slug) await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("") await expect(page).toHaveURL(new RegExp(`/${slug}/session/[^/?#]+`), { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url()) const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`))
return sessionID return sessionID
} }
+26 -60
View File
@@ -11,12 +11,18 @@ import {
cleanupTestProject, cleanupTestProject,
clickMenuItem, clickMenuItem,
confirmDialog, confirmDialog,
openProjectMenu,
openSidebar, openSidebar,
openWorkspaceMenu, openWorkspaceMenu,
setWorkspacesEnabled, setWorkspacesEnabled,
} from "../actions" } from "../actions"
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors" import {
import { createSdk, dirSlug } from "../utils" inlineInputSelector,
projectSwitchSelector,
projectWorkspacesToggleSelector,
workspaceItemSelector,
} from "../selectors"
import { dirSlug } from "../utils"
function slugFromUrl(url: string) { function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
@@ -137,35 +143,26 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n") await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
try { try {
await withProject(async () => { await withProject(
await page.goto(`/${nonGitSlug}/session`) async () => {
await openSidebar(page)
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("") const nonGitButton = page.locator(projectSwitchSelector(nonGitSlug)).first()
await expect(nonGitButton).toBeVisible()
await nonGitButton.click()
await expect(page).toHaveURL(new RegExp(`/${nonGitSlug}/session`))
const activeDir = base64Decode(slugFromUrl(page.url())) const menu = await openProjectMenu(page, nonGitSlug)
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-") const toggle = menu.locator(projectWorkspacesToggleSelector(nonGitSlug)).first()
await openSidebar(page) await expect(toggle).toBeVisible()
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0) await expect(toggle).toBeDisabled()
const trigger = page.locator('[data-action="project-menu"]').first() await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
const hasMenu = await trigger await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
.isVisible() },
.then((x) => x) { extra: [nonGit] },
.catch(() => false) )
if (!hasMenu) return
await trigger.click({ force: true })
const menu = page.locator(dropdownMenuContentSelector).first()
await expect(menu).toBeVisible()
const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first()
await expect(toggle).toBeVisible()
await expect(toggle).toBeDisabled()
await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
})
} finally { } finally {
await cleanupTestProject(nonGit) await cleanupTestProject(nonGit)
} }
@@ -259,45 +256,14 @@ test("can delete a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async (project) => { await withProject(async (project) => {
const sdk = createSdk(project.directory) const { rootSlug, slug } = await setupWorkspaceTest(page, project)
const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
await expect
.poll(
async () => {
const worktrees = await sdk.worktree
.list()
.then((r) => r.data ?? [])
.catch(() => [] as string[])
return worktrees.includes(directory)
},
{ timeout: 30_000 },
)
.toBe(true)
const menu = await openWorkspaceMenu(page, slug) const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Delete$/i, { force: true }) await clickMenuItem(menu, /^Delete$/i, { force: true })
await confirmDialog(page, /^Delete workspace$/i) await confirmDialog(page, /^Delete workspace$/i)
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`)) await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
await expect
.poll(
async () => {
const worktrees = await sdk.worktree
.list()
.then((r) => r.data ?? [])
.catch(() => [] as string[])
return worktrees.includes(directory)
},
{ timeout: 60_000 },
)
.toBe(false)
await project.gotoSession()
await openSidebar(page)
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible() await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
}) })
}) })
+24 -79
View File
@@ -1,95 +1,40 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import type { Page } from "@playwright/test"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { withSession } from "../actions" import { withSession } from "../actions"
function contextButton(page: Page) {
return page
.locator('[data-component="button"]')
.filter({ has: page.locator('[data-component="progress-circle"]').first() })
.first()
}
async function seedContextSession(input: { sessionID: string; sdk: Parameters<typeof withSession>[0] }) {
await input.sdk.session.promptAsync({
sessionID: input.sessionID,
noReply: true,
parts: [
{
type: "text",
text: "seed context",
},
],
})
await expect
.poll(async () => {
const messages = await input.sdk.session
.messages({ sessionID: input.sessionID, limit: 1 })
.then((r) => r.data ?? [])
return messages.length
})
.toBeGreaterThan(0)
}
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => { test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
const title = `e2e smoke context ${Date.now()}` const title = `e2e smoke context ${Date.now()}`
await withSession(sdk, title, async (session) => { await withSession(sdk, title, async (session) => {
await seedContextSession({ sessionID: session.id, sdk }) await sdk.session.promptAsync({
sessionID: session.id,
noReply: true,
parts: [
{
type: "text",
text: "seed context",
},
],
})
await expect
.poll(async () => {
const messages = await sdk.session.messages({ sessionID: session.id, limit: 1 }).then((r) => r.data ?? [])
return messages.length
})
.toBeGreaterThan(0)
await gotoSession(session.id) await gotoSession(session.id)
const trigger = contextButton(page) const contextButton = page
await expect(trigger).toBeVisible() .locator('[data-component="button"]')
await trigger.click() .filter({ has: page.locator('[data-component="progress-circle"]').first() })
.first()
await expect(contextButton).toBeVisible()
await contextButton.click()
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]') const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible() await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
}) })
}) })
test("context panel can be closed from the context tab close action", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, `e2e context toggle ${Date.now()}`, async (session) => {
await seedContextSession({ sessionID: session.id, sdk })
await gotoSession(session.id)
await page.locator(promptSelector).click()
const trigger = contextButton(page)
await expect(trigger).toBeVisible()
await trigger.click()
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
const context = tabs.getByRole("tab", { name: "Context" })
await expect(context).toBeVisible()
await page.getByRole("button", { name: "Close tab" }).first().click()
await expect(context).toHaveCount(0)
})
})
test("context panel can open file picker from context actions", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, `e2e context tabs ${Date.now()}`, async (session) => {
await seedContextSession({ sessionID: session.id, sdk })
await gotoSession(session.id)
await page.locator(promptSelector).click()
const trigger = contextButton(page)
await expect(trigger).toBeVisible()
await trigger.click()
await expect(page.getByRole("tab", { name: "Context" })).toBeVisible()
await page.getByRole("button", { name: "Open file" }).first().click()
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})
})
@@ -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)
}
})
+3
View File
@@ -44,6 +44,9 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
) )
.toContain(token) .toContain(token)
const reply = page.locator('[data-slot="session-turn-summary-section"]').filter({ hasText: token }).first()
await expect(reply).toBeVisible({ timeout: 90_000 })
} finally { } finally {
page.off("pageerror", onPageError) page.off("pageerror", onPageError)
await sdk.session.delete({ sessionID }).catch(() => undefined) await sdk.session.delete({ sessionID }).catch(() => undefined)
-6
View File
@@ -10,11 +10,8 @@ export const settingsNotificationsAgentSelector = '[data-action="settings-notifi
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]' export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]' export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]' export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]'
export const settingsSoundsAgentEnabledSelector = '[data-action="settings-sounds-agent-enabled"]'
export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]' export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]'
export const settingsSoundsPermissionsEnabledSelector = '[data-action="settings-sounds-permissions-enabled"]'
export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]' export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]'
export const settingsSoundsErrorsEnabledSelector = '[data-action="settings-sounds-errors-enabled"]'
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]' export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]' export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
@@ -30,9 +27,6 @@ export const projectMenuTriggerSelector = (slug: string) =>
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]` 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) => export const projectWorkspacesToggleSelector = (slug: string) =>
`[data-action="project-workspaces-toggle"][data-project="${slug}"]` `[data-action="project-workspaces-toggle"][data-project="${slug}"]`
@@ -10,26 +10,21 @@ async function seedConversation(input: {
sessionID: string sessionID: string
token: string token: string
}) { }) {
const messages = async () =>
await input.sdk.session.messages({ sessionID: input.sessionID, limit: 100 }).then((r) => r.data ?? [])
const seeded = await messages()
const userIDs = new Set(seeded.filter((m) => m.info.role === "user").map((m) => m.info.id))
const prompt = input.page.locator(promptSelector) const prompt = input.page.locator(promptSelector)
await expect(prompt).toBeVisible() await expect(prompt).toBeVisible()
await input.sdk.session.promptAsync({ await prompt.click()
sessionID: input.sessionID, await input.page.keyboard.type(`Reply with exactly: ${input.token}`)
noReply: true, await input.page.keyboard.press("Enter")
parts: [{ type: "text", text: input.token }],
})
let userMessageID: string | undefined let userMessageID: string | undefined
await expect await expect
.poll( .poll(
async () => { async () => {
const users = (await messages()).filter( const messages = await input.sdk.session
.messages({ sessionID: input.sessionID, limit: 50 })
.then((r) => r.data ?? [])
const users = messages.filter(
(m) => (m) =>
!userIDs.has(m.info.id) &&
m.info.role === "user" && m.info.role === "user" &&
m.parts.filter((p) => p.type === "text").some((p) => p.text.includes(input.token)), m.parts.filter((p) => p.type === "text").some((p) => p.text.includes(input.token)),
) )
@@ -38,14 +33,21 @@ async function seedConversation(input: {
const user = users[users.length - 1] const user = users[users.length - 1]
if (!user) return false if (!user) return false
userMessageID = user.info.id userMessageID = user.info.id
return true
const assistantText = messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
return assistantText.includes(input.token)
}, },
{ timeout: 90_000, intervals: [250, 500, 1_000] }, { timeout: 90_000 },
) )
.toBe(true) .toBe(true)
if (!userMessageID) throw new Error("Expected a user message id") if (!userMessageID) throw new Error("Expected a user message id")
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`).first()).toBeVisible({ timeout: 30_000 })
return { prompt, userMessageID } return { prompt, userMessageID }
} }
+14 -31
View File
@@ -34,34 +34,21 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => { test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
const stamp = Date.now() const stamp = Date.now()
const originalTitle = `e2e rename test ${stamp}` const originalTitle = `e2e rename test ${stamp}`
const renamedTitle = `e2e renamed ${stamp}` const newTitle = `e2e renamed ${stamp}`
await withSession(sdk, originalTitle, async (session) => { await withSession(sdk, originalTitle, async (session) => {
await seedMessage(sdk, session.id) await seedMessage(sdk, session.id)
await gotoSession(session.id) await gotoSession(session.id)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
const menu = await openSessionMoreMenu(page, session.id) const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i) await clickMenuItem(menu, /rename/i)
const input = page.locator(".session-scroller").locator(inlineInputSelector).first() const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
await expect(input).toBeVisible() await expect(input).toBeVisible()
await expect(input).toBeFocused() await input.fill(newTitle)
await input.fill(renamedTitle)
await expect(input).toHaveValue(renamedTitle)
await input.press("Enter") await input.press("Enter")
await expect await expect(page.getByRole("heading", { level: 1 }).first()).toContainText(newTitle)
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.title
},
{ timeout: 30_000 },
)
.toBe(renamedTitle)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
}) })
}) })
@@ -129,14 +116,8 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
await seedMessage(sdk, session.id) await seedMessage(sdk, session.id)
await gotoSession(session.id) await gotoSession(session.id)
const shared = await openSharePopover(page) const { rightSection, popoverBody } = await openSharePopover(page)
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first() await popoverBody.getByRole("button", { name: "Publish" }).first().click()
await expect(publish).toBeVisible({ timeout: 30_000 })
await publish.click()
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
timeout: 30_000,
})
await expect await expect
.poll( .poll(
@@ -148,14 +129,14 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
) )
.not.toBeUndefined() .not.toBeUndefined()
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first() const copyButton = rightSection.locator('button[aria-label="Copy link"]').first()
await expect(copyButton).toBeVisible({ timeout: 30_000 })
const sharedPopover = await openSharePopover(page)
const unpublish = sharedPopover.popoverBody.getByRole("button", { name: "Unpublish" }).first()
await expect(unpublish).toBeVisible({ timeout: 30_000 }) await expect(unpublish).toBeVisible({ timeout: 30_000 })
await unpublish.click() await unpublish.click()
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000,
})
await expect await expect
.poll( .poll(
async () => { async () => {
@@ -166,8 +147,10 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
) )
.toBeUndefined() .toBeUndefined()
const unshared = await openSharePopover(page) await expect(copyButton).not.toBeVisible({ timeout: 30_000 })
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
const unsharedPopover = await openSharePopover(page)
await expect(unsharedPopover.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000, timeout: 30_000,
}) })
}) })
@@ -9,7 +9,6 @@ import {
settingsNotificationsPermissionsSelector, settingsNotificationsPermissionsSelector,
settingsReleaseNotesSelector, settingsReleaseNotesSelector,
settingsSoundsAgentSelector, settingsSoundsAgentSelector,
settingsSoundsAgentEnabledSelector,
settingsSoundsErrorsSelector, settingsSoundsErrorsSelector,
settingsSoundsPermissionsSelector, settingsSoundsPermissionsSelector,
settingsThemeSelector, settingsThemeSelector,
@@ -336,30 +335,6 @@ test("changing sound agent selection persists in localStorage", async ({ page, g
expect(stored?.sounds?.agent).not.toBe("staplebops-01") expect(stored?.sounds?.agent).not.toBe("staplebops-01")
}) })
test("disabling agent sound disables sound selection", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const select = dialog.locator(settingsSoundsAgentSelector)
const switchContainer = dialog.locator(settingsSoundsAgentEnabledSelector)
const trigger = select.locator('[data-slot="select-select-trigger"]')
await expect(select).toBeVisible()
await expect(switchContainer).toBeVisible()
await expect(trigger).toBeEnabled()
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
await expect(trigger).toBeDisabled()
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.sounds?.agentEnabled).toBe(false)
})
test("changing permissions and errors sounds updates localStorage", async ({ page, gotoSession }) => { test("changing permissions and errors sounds updates localStorage", async ({ page, gotoSession }) => {
await gotoSession() await gotoSession()
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.2.6", "version": "1.1.59",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
+69 -97
View File
@@ -1,5 +1,5 @@
import "@/index.css" import "@/index.css"
import { ErrorBoundary, Show, Suspense, lazy, type JSX, type ParentProps } from "solid-js" import { ErrorBoundary, Show, lazy, type ParentProps } from "solid-js"
import { Router, Route, Navigate } from "@solidjs/router" import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font" import { Font } from "@opencode-ai/ui/font"
@@ -30,26 +30,12 @@ import { HighlightsProvider } from "@/context/highlights"
import Layout from "@/pages/layout" import Layout from "@/pages/layout"
import DirectoryLayout from "@/pages/directory-layout" import DirectoryLayout from "@/pages/directory-layout"
import { ErrorPage } from "./pages/error" import { ErrorPage } from "./pages/error"
import { Suspense, JSX } from "solid-js"
const Home = lazy(() => import("@/pages/home")) const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session")) const Session = lazy(() => import("@/pages/session"))
const Loading = () => <div class="size-full" /> const Loading = () => <div class="size-full" />
const HomeRoute = () => (
<Suspense fallback={<Loading />}>
<Home />
</Suspense>
)
const SessionRoute = () => (
<SessionProviders>
<Suspense fallback={<Loading />}>
<Session />
</Suspense>
</SessionProviders>
)
const SessionIndexRoute = () => <Navigate href="session" />
function UiI18nBridge(props: ParentProps) { function UiI18nBridge(props: ParentProps) {
const language = useLanguage() const language = useLanguage()
return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider> return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider>
@@ -66,71 +52,6 @@ function MarkedProviderWithNativeParser(props: ParentProps) {
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider> return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
} }
function AppShellProviders(props: ParentProps) {
return (
<SettingsProvider>
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
<ModelsProvider>
<CommandProvider>
<HighlightsProvider>
<Layout>{props.children}</Layout>
</HighlightsProvider>
</CommandProvider>
</ModelsProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
</SettingsProvider>
)
}
function SessionProviders(props: ParentProps) {
return (
<TerminalProvider>
<FileProvider>
<PromptProvider>
<CommentsProvider>{props.children}</CommentsProvider>
</PromptProvider>
</FileProvider>
</TerminalProvider>
)
}
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
return (
<AppShellProviders>
{props.appChildren}
{props.children}
</AppShellProviders>
)
}
const getStoredDefaultServerUrl = (platform: ReturnType<typeof usePlatform>) => {
if (platform.platform !== "web") return
const result = platform.getDefaultServerUrl?.()
if (result instanceof Promise) return
if (!result) return
return normalizeServerUrl(result)
}
const resolveDefaultServerUrl = (props: {
defaultUrl?: string
storedDefaultServerUrl?: string
hostname: string
origin: string
isDev: boolean
devHost?: string
devPort?: string
}) => {
if (props.defaultUrl) return props.defaultUrl
if (props.storedDefaultServerUrl) return props.storedDefaultServerUrl
if (props.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (props.isDev) return `http://${props.devHost ?? "localhost"}:${props.devPort ?? "4096"}`
return props.origin
}
export function AppBaseProviders(props: ParentProps) { export function AppBaseProviders(props: ParentProps) {
return ( return (
<MetaProvider> <MetaProvider>
@@ -165,29 +86,80 @@ function ServerKey(props: ParentProps) {
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) { export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
const platform = usePlatform() const platform = usePlatform()
const storedDefaultServerUrl = getStoredDefaultServerUrl(platform)
const defaultServerUrl = resolveDefaultServerUrl({ const stored = (() => {
defaultUrl: props.defaultUrl, if (platform.platform !== "web") return
storedDefaultServerUrl, const result = platform.getDefaultServerUrl?.()
hostname: location.hostname, if (result instanceof Promise) return
origin: window.location.origin, if (!result) return
isDev: import.meta.env.DEV, return normalizeServerUrl(result)
devHost: import.meta.env.VITE_OPENCODE_SERVER_HOST, })()
devPort: import.meta.env.VITE_OPENCODE_SERVER_PORT,
}) const defaultServerUrl = () => {
if (props.defaultUrl) return props.defaultUrl
if (stored) return stored
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return window.location.origin
}
return ( return (
<ServerProvider defaultUrl={defaultServerUrl} isSidecar={props.isSidecar}> <ServerProvider defaultUrl={defaultServerUrl()} isSidecar={props.isSidecar}>
<ServerKey> <ServerKey>
<GlobalSDKProvider> <GlobalSDKProvider>
<GlobalSyncProvider> <GlobalSyncProvider>
<Router <Router
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>} root={(routerProps) => (
<SettingsProvider>
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
<ModelsProvider>
<CommandProvider>
<HighlightsProvider>
<Layout>
{props.children}
{routerProps.children}
</Layout>
</HighlightsProvider>
</CommandProvider>
</ModelsProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
</SettingsProvider>
)}
> >
<Route path="/" component={HomeRoute} /> <Route
path="/"
component={() => (
<Suspense fallback={<Loading />}>
<Home />
</Suspense>
)}
/>
<Route path="/:dir" component={DirectoryLayout}> <Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={SessionIndexRoute} /> <Route path="/" component={() => <Navigate href="session" />} />
<Route path="/session/:id?" component={SessionRoute} /> <Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id ?? "new"}>
<TerminalProvider>
<FileProvider>
<PromptProvider>
<CommentsProvider>
<Suspense fallback={<Loading />}>
<Session />
</Suspense>
</CommentsProvider>
</PromptProvider>
</FileProvider>
</TerminalProvider>
</Show>
)}
/>
</Route> </Route>
</Router> </Router>
</GlobalSyncProvider> </GlobalSyncProvider>
@@ -10,6 +10,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { iife } from "@opencode-ai/util/iife"
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js" import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link" import { Link } from "@/components/link"
@@ -54,47 +55,6 @@ export function DialogConnectProvider(props: { provider: string }) {
error: undefined as string | undefined, error: undefined as string | undefined,
}) })
type Action =
| { type: "method.select"; index: number }
| { type: "method.reset" }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
| { type: "auth.error"; error: string }
function dispatch(action: Action) {
setStore(
produce((draft) => {
if (action.type === "method.select") {
draft.methodIndex = action.index
draft.authorization = undefined
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "method.reset") {
draft.methodIndex = undefined
draft.authorization = undefined
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "auth.pending") {
draft.state = "pending"
draft.error = undefined
return
}
if (action.type === "auth.complete") {
draft.state = "complete"
draft.authorization = action.authorization
draft.error = undefined
return
}
draft.state = "error"
draft.error = action.error
}),
)
}
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined)) const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
const methodLabel = (value?: { type?: string; label?: string }) => { const methodLabel = (value?: { type?: string; label?: string }) => {
@@ -103,24 +63,6 @@ export function DialogConnectProvider(props: { provider: string }) {
return value.label ?? "" return value.label ?? ""
} }
function formatError(value: unknown, fallback: string): string {
if (value && typeof value === "object" && "data" in value) {
const data = (value as { data?: { message?: unknown } }).data
if (typeof data?.message === "string" && data.message) return data.message
}
if (value && typeof value === "object" && "error" in value) {
const nested = formatError((value as { error?: unknown }).error, "")
if (nested) return nested
}
if (value && typeof value === "object" && "message" in value) {
const message = (value as { message?: unknown }).message
if (typeof message === "string" && message) return message
}
if (value instanceof Error && value.message) return value.message
if (typeof value === "string" && value) return value
return fallback
}
async function selectMethod(index: number) { async function selectMethod(index: number) {
if (timer.current !== undefined) { if (timer.current !== undefined) {
clearTimeout(timer.current) clearTimeout(timer.current)
@@ -128,10 +70,17 @@ export function DialogConnectProvider(props: { provider: string }) {
} }
const method = methods()[index] const method = methods()[index]
dispatch({ type: "method.select", index }) setStore(
produce((draft) => {
draft.methodIndex = index
draft.authorization = undefined
draft.state = undefined
draft.error = undefined
}),
)
if (method.type === "oauth") { if (method.type === "oauth") {
dispatch({ type: "auth.pending" }) setStore("state", "pending")
const start = Date.now() const start = Date.now()
await globalSDK.client.provider.oauth await globalSDK.client.provider.oauth
.authorize( .authorize(
@@ -151,15 +100,18 @@ export function DialogConnectProvider(props: { provider: string }) {
timer.current = setTimeout(() => { timer.current = setTimeout(() => {
timer.current = undefined timer.current = undefined
if (!alive.value) return if (!alive.value) return
dispatch({ type: "auth.complete", authorization: x.data! }) setStore("state", "complete")
setStore("authorization", x.data!)
}, delay) }, delay)
return return
} }
dispatch({ type: "auth.complete", authorization: x.data! }) setStore("state", "complete")
setStore("authorization", x.data!)
}) })
.catch((e) => { .catch((e) => {
if (!alive.value) return if (!alive.value) return
dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) }) setStore("state", "error")
setStore("error", String(e))
}) })
} }
} }
@@ -177,6 +129,10 @@ export function DialogConnectProvider(props: { provider: string }) {
if (methods().length === 1) { if (methods().length === 1) {
selectMethod(0) selectMethod(0)
} }
document.addEventListener("keydown", handleKey)
onCleanup(() => {
document.removeEventListener("keydown", handleKey)
})
}) })
async function complete() { async function complete() {
@@ -196,243 +152,17 @@ export function DialogConnectProvider(props: { provider: string }) {
return return
} }
if (store.authorization) { if (store.authorization) {
dispatch({ type: "method.reset" }) setStore("authorization", undefined)
setStore("methodIndex", undefined)
return return
} }
if (store.methodIndex !== undefined) { if (store.methodIndex) {
dispatch({ type: "method.reset" }) setStore("methodIndex", undefined)
return return
} }
dialog.show(() => <DialogSelectProvider />) dialog.show(() => <DialogSelectProvider />)
} }
function MethodSelection() {
return (
<>
<div class="text-14-regular text-text-base">
{language.t("provider.connect.selectMethod", { provider: provider().name })}
</div>
<div>
<List
ref={(ref) => {
listRef = ref
}}
items={methods}
key={(m) => m?.label}
onSelect={async (selected, index) => {
if (!selected) return
selectMethod(index)
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-2">
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
</div>
<span>{methodLabel(i)}</span>
</div>
)}
</List>
</div>
</>
)
}
function ApiAuthView() {
const [formStore, setFormStore] = createStore({
value: "",
error: undefined as string | undefined,
})
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const form = e.currentTarget as HTMLFormElement
const formData = new FormData(form)
const apiKey = formData.get("apiKey") as string
if (!apiKey?.trim()) {
setFormStore("error", language.t("provider.connect.apiKey.required"))
return
}
setFormStore("error", undefined)
await globalSDK.client.auth.set({
providerID: props.provider,
auth: {
type: "api",
key: apiKey,
},
})
await complete()
}
return (
<div class="flex flex-col gap-6">
<Switch>
<Match when={provider().id === "opencode"}>
<div class="flex flex-col gap-4">
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line1")}</div>
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div>
<div class="text-14-regular text-text-base">
{language.t("provider.connect.opencodeZen.visit.prefix")}
<Link href="https://opencode.ai/zen" tabIndex={-1}>
{language.t("provider.connect.opencodeZen.visit.link")}
</Link>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
</div>
</Match>
<Match when={true}>
<div class="text-14-regular text-text-base">
{language.t("provider.connect.apiKey.description", { provider: provider().name })}
</div>
</Match>
</Switch>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<TextField
autofocus
type="text"
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
placeholder={language.t("provider.connect.apiKey.placeholder")}
name="apiKey"
value={formStore.value}
onChange={(v) => setFormStore("value", v)}
validationState={formStore.error ? "invalid" : undefined}
error={formStore.error}
/>
<Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.submit")}
</Button>
</form>
</div>
)
}
function OAuthCodeView() {
const [formStore, setFormStore] = createStore({
value: "",
error: undefined as string | undefined,
})
onMount(() => {
if (store.authorization?.method === "code" && store.authorization?.url) {
platform.openLink(store.authorization.url)
}
})
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const form = e.currentTarget as HTMLFormElement
const formData = new FormData(form)
const code = formData.get("code") as string
if (!code?.trim()) {
setFormStore("error", language.t("provider.connect.oauth.code.required"))
return
}
setFormStore("error", undefined)
const result = await globalSDK.client.provider.oauth
.callback({
providerID: props.provider,
method: store.methodIndex,
code,
})
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (result.ok) {
await complete()
return
}
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
}
return (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.code.visit.prefix")}
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<TextField
autofocus
type="text"
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
placeholder={language.t("provider.connect.oauth.code.placeholder")}
name="code"
value={formStore.value}
onChange={(v) => setFormStore("value", v)}
validationState={formStore.error ? "invalid" : undefined}
error={formStore.error}
/>
<Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.submit")}
</Button>
</form>
</div>
)
}
function OAuthAutoView() {
const code = createMemo(() => {
const instructions = store.authorization?.instructions
if (instructions?.includes(":")) {
return instructions.split(":")[1]?.trim()
}
return instructions
})
onMount(() => {
void (async () => {
if (store.authorization?.url) {
platform.openLink(store.authorization.url)
}
const result = await globalSDK.client.provider.oauth
.callback({
providerID: props.provider,
method: store.methodIndex,
})
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (!alive.value) return
if (!result.ok) {
const message = formatError(result.error, language.t("common.requestFailed"))
dispatch({ type: "auth.error", error: message })
return
}
await complete()
})()
})
return (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")}
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
</div>
<TextField
label={language.t("provider.connect.oauth.auto.confirmationCode")}
class="font-mono"
value={code()}
readOnly
copyable
/>
<div class="text-14-regular text-text-base flex items-center gap-4">
<Spinner />
<span>{language.t("provider.connect.status.waiting")}</span>
</div>
</div>
)
}
return ( return (
<Dialog <Dialog
title={ title={
@@ -458,42 +188,267 @@ export function DialogConnectProvider(props: { provider: string }) {
</div> </div>
</div> </div>
<div class="px-2.5 pb-10 flex flex-col gap-6"> <div class="px-2.5 pb-10 flex flex-col gap-6">
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}> <Switch>
<Switch> <Match when={store.methodIndex === undefined}>
<Match when={store.methodIndex === undefined}> <div class="text-14-regular text-text-base">
<MethodSelection /> {language.t("provider.connect.selectMethod", { provider: provider().name })}
</Match> </div>
<Match when={store.state === "pending"}> <div class="">
<div class="text-14-regular text-text-base"> <List
<div class="flex items-center gap-x-2"> ref={(ref) => {
<Spinner /> listRef = ref
<span>{language.t("provider.connect.status.inProgress")}</span> }}
</div> items={methods}
key={(m) => m?.label}
onSelect={async (method, index) => {
if (!method) return
selectMethod(index)
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-2">
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
</div>
<span>{methodLabel(i)}</span>
</div>
)}
</List>
</div>
</Match>
<Match when={store.state === "pending"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div> </div>
</Match> </div>
<Match when={store.state === "error"}> </Match>
<div class="text-14-regular text-text-base"> <Match when={store.state === "error"}>
<div class="flex items-center gap-x-2"> <div class="text-14-regular text-text-base">
<Icon name="circle-ban-sign" class="text-icon-critical-base" /> <div class="flex items-center gap-x-2">
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span> <Icon name="circle-ban-sign" class="text-icon-critical-base" />
</div> <span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
</div> </div>
</Match> </div>
<Match when={method()?.type === "api"}> </Match>
<ApiAuthView /> <Match when={method()?.type === "api"}>
</Match> {iife(() => {
<Match when={method()?.type === "oauth"}> const [formStore, setFormStore] = createStore({
<Switch> value: "",
<Match when={store.authorization?.method === "code"}> error: undefined as string | undefined,
<OAuthCodeView /> })
</Match>
<Match when={store.authorization?.method === "auto"}> async function handleSubmit(e: SubmitEvent) {
<OAuthAutoView /> e.preventDefault()
</Match>
</Switch> const form = e.currentTarget as HTMLFormElement
</Match> const formData = new FormData(form)
</Switch> const apiKey = formData.get("apiKey") as string
</div>
if (!apiKey?.trim()) {
setFormStore("error", language.t("provider.connect.apiKey.required"))
return
}
setFormStore("error", undefined)
await globalSDK.client.auth.set({
providerID: props.provider,
auth: {
type: "api",
key: apiKey,
},
})
await complete()
}
return (
<div class="flex flex-col gap-6">
<Switch>
<Match when={provider().id === "opencode"}>
<div class="flex flex-col gap-4">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.opencodeZen.line1")}
</div>
<div class="text-14-regular text-text-base">
{language.t("provider.connect.opencodeZen.line2")}
</div>
<div class="text-14-regular text-text-base">
{language.t("provider.connect.opencodeZen.visit.prefix")}
<Link href="https://opencode.ai/zen" tabIndex={-1}>
{language.t("provider.connect.opencodeZen.visit.link")}
</Link>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
</div>
</Match>
<Match when={true}>
<div class="text-14-regular text-text-base">
{language.t("provider.connect.apiKey.description", { provider: provider().name })}
</div>
</Match>
</Switch>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<TextField
autofocus
type="text"
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
placeholder={language.t("provider.connect.apiKey.placeholder")}
name="apiKey"
value={formStore.value}
onChange={setFormStore.bind(null, "value")}
validationState={formStore.error ? "invalid" : undefined}
error={formStore.error}
/>
<Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.submit")}
</Button>
</form>
</div>
)
})}
</Match>
<Match when={method()?.type === "oauth"}>
<Switch>
<Match when={store.authorization?.method === "code"}>
{iife(() => {
const [formStore, setFormStore] = createStore({
value: "",
error: undefined as string | undefined,
})
onMount(() => {
if (store.authorization?.method === "code" && store.authorization?.url) {
platform.openLink(store.authorization.url)
}
})
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const form = e.currentTarget as HTMLFormElement
const formData = new FormData(form)
const code = formData.get("code") as string
if (!code?.trim()) {
setFormStore("error", language.t("provider.connect.oauth.code.required"))
return
}
setFormStore("error", undefined)
const result = await globalSDK.client.provider.oauth
.callback({
providerID: props.provider,
method: store.methodIndex,
code,
})
.then((value) =>
value.error ? { ok: false as const, error: value.error } : { ok: true as const },
)
.catch((error) => ({ ok: false as const, error }))
if (result.ok) {
await complete()
return
}
const message = result.error instanceof Error ? result.error.message : String(result.error)
setFormStore("error", message || language.t("provider.connect.oauth.code.invalid"))
}
return (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.code.visit.prefix")}
<Link href={store.authorization!.url}>
{language.t("provider.connect.oauth.code.visit.link")}
</Link>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<TextField
autofocus
type="text"
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
placeholder={language.t("provider.connect.oauth.code.placeholder")}
name="code"
value={formStore.value}
onChange={setFormStore.bind(null, "value")}
validationState={formStore.error ? "invalid" : undefined}
error={formStore.error}
/>
<Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.submit")}
</Button>
</form>
</div>
)
})}
</Match>
<Match when={store.authorization?.method === "auto"}>
{iife(() => {
const code = createMemo(() => {
const instructions = store.authorization?.instructions
if (instructions?.includes(":")) {
return instructions?.split(":")[1]?.trim()
}
return instructions
})
onMount(() => {
void (async () => {
if (store.authorization?.url) {
platform.openLink(store.authorization.url)
}
const result = await globalSDK.client.provider.oauth
.callback({
providerID: props.provider,
method: store.methodIndex,
})
.then((value) =>
value.error ? { ok: false as const, error: value.error } : { ok: true as const },
)
.catch((error) => ({ ok: false as const, error }))
if (!alive.value) return
if (!result.ok) {
const message = result.error instanceof Error ? result.error.message : String(result.error)
setStore("state", "error")
setStore("error", message)
return
}
await complete()
})()
})
return (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")}
<Link href={store.authorization!.url}>
{language.t("provider.connect.oauth.auto.visit.link")}
</Link>
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
</div>
<TextField
label={language.t("provider.connect.oauth.auto.confirmationCode")}
class="font-mono"
value={code()}
readOnly
copyable
/>
<div class="text-14-regular text-text-base flex items-center gap-4">
<Spinner />
<span>{language.t("provider.connect.status.waiting")}</span>
</div>
</div>
)
})}
</Match>
</Switch>
</Match>
</Switch>
</div> </div>
</div> </div>
</Dialog> </Dialog>
@@ -6,7 +6,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { For } from "solid-js" import { For } from "solid-js"
import { createStore } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link" import { Link } from "@/components/link"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
@@ -16,147 +16,6 @@ import { DialogSelectProvider } from "./dialog-select-provider"
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/ const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible" const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
type Translator = ReturnType<typeof useLanguage>["t"]
type ModelRow = {
id: string
name: string
}
type HeaderRow = {
key: string
value: string
}
type FormState = {
providerID: string
name: string
baseURL: string
apiKey: string
models: ModelRow[]
headers: HeaderRow[]
saving: boolean
}
type FormErrors = {
providerID: string | undefined
name: string | undefined
baseURL: string | undefined
models: Array<{ id?: string; name?: string }>
headers: Array<{ key?: string; value?: string }>
}
type ValidateArgs = {
form: FormState
t: Translator
disabledProviders: string[]
existingProviderIDs: Set<string>
}
function validateCustomProvider(input: ValidateArgs) {
const providerID = input.form.providerID.trim()
const name = input.form.name.trim()
const baseURL = input.form.baseURL.trim()
const apiKey = input.form.apiKey.trim()
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
const key = apiKey && !env ? apiKey : undefined
const idError = !providerID
? input.t("provider.custom.error.providerID.required")
: !PROVIDER_ID.test(providerID)
? input.t("provider.custom.error.providerID.format")
: undefined
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
const urlError = !baseURL
? input.t("provider.custom.error.baseURL.required")
: !/^https?:\/\//.test(baseURL)
? input.t("provider.custom.error.baseURL.format")
: undefined
const disabled = input.disabledProviders.includes(providerID)
const existsError = idError
? undefined
: input.existingProviderIDs.has(providerID) && !disabled
? input.t("provider.custom.error.providerID.exists")
: undefined
const seenModels = new Set<string>()
const modelErrors = input.form.models.map((m) => {
const id = m.id.trim()
const modelIdError = !id
? input.t("provider.custom.error.required")
: seenModels.has(id)
? input.t("provider.custom.error.duplicate")
: (() => {
seenModels.add(id)
return undefined
})()
const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
return { id: modelIdError, name: modelNameError }
})
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
const seenHeaders = new Set<string>()
const headerErrors = input.form.headers.map((h) => {
const key = h.key.trim()
const value = h.value.trim()
if (!key && !value) return {}
const keyError = !key
? input.t("provider.custom.error.required")
: seenHeaders.has(key.toLowerCase())
? input.t("provider.custom.error.duplicate")
: (() => {
seenHeaders.add(key.toLowerCase())
return undefined
})()
const valueError = !value ? input.t("provider.custom.error.required") : undefined
return { key: keyError, value: valueError }
})
const headersValid = headerErrors.every((h) => !h.key && !h.value)
const headers = Object.fromEntries(
input.form.headers
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
.filter((h) => !!h.key && !!h.value)
.map((h) => [h.key, h.value]),
)
const errors: FormErrors = {
providerID: idError ?? existsError,
name: nameError,
baseURL: urlError,
models: modelErrors,
headers: headerErrors,
}
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
if (!ok) return { errors }
const options = {
baseURL,
...(Object.keys(headers).length ? { headers } : {}),
}
return {
errors,
result: {
providerID,
name,
key,
config: {
npm: OPENAI_COMPATIBLE,
name,
...(env ? { env: [env] } : {}),
options,
models,
},
},
}
}
type Props = { type Props = {
back?: "providers" | "close" back?: "providers" | "close"
} }
@@ -167,7 +26,7 @@ export function DialogCustomProvider(props: Props) {
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const language = useLanguage() const language = useLanguage()
const [form, setForm] = createStore<FormState>({ const [form, setForm] = createStore({
providerID: "", providerID: "",
name: "", name: "",
baseURL: "", baseURL: "",
@@ -177,12 +36,12 @@ export function DialogCustomProvider(props: Props) {
saving: false, saving: false,
}) })
const [errors, setErrors] = createStore<FormErrors>({ const [errors, setErrors] = createStore({
providerID: undefined, providerID: undefined as string | undefined,
name: undefined, name: undefined as string | undefined,
baseURL: undefined, baseURL: undefined as string | undefined,
models: [{}], models: [{} as { id?: string; name?: string }],
headers: [{}], headers: [{} as { key?: string; value?: string }],
}) })
const goBack = () => { const goBack = () => {
@@ -194,36 +53,169 @@ export function DialogCustomProvider(props: Props) {
} }
const addModel = () => { const addModel = () => {
setForm("models", (v) => [...v, { id: "", name: "" }]) setForm(
setErrors("models", (v) => [...v, {}]) "models",
produce((draft) => {
draft.push({ id: "", name: "" })
}),
)
setErrors(
"models",
produce((draft) => {
draft.push({})
}),
)
} }
const removeModel = (index: number) => { const removeModel = (index: number) => {
if (form.models.length <= 1) return if (form.models.length <= 1) return
setForm("models", (v) => v.filter((_, i) => i !== index)) setForm(
setErrors("models", (v) => v.filter((_, i) => i !== index)) "models",
produce((draft) => {
draft.splice(index, 1)
}),
)
setErrors(
"models",
produce((draft) => {
draft.splice(index, 1)
}),
)
} }
const addHeader = () => { const addHeader = () => {
setForm("headers", (v) => [...v, { key: "", value: "" }]) setForm(
setErrors("headers", (v) => [...v, {}]) "headers",
produce((draft) => {
draft.push({ key: "", value: "" })
}),
)
setErrors(
"headers",
produce((draft) => {
draft.push({})
}),
)
} }
const removeHeader = (index: number) => { const removeHeader = (index: number) => {
if (form.headers.length <= 1) return if (form.headers.length <= 1) return
setForm("headers", (v) => v.filter((_, i) => i !== index)) setForm(
setErrors("headers", (v) => v.filter((_, i) => i !== index)) "headers",
produce((draft) => {
draft.splice(index, 1)
}),
)
setErrors(
"headers",
produce((draft) => {
draft.splice(index, 1)
}),
)
} }
const validate = () => { const validate = () => {
const output = validateCustomProvider({ const providerID = form.providerID.trim()
form, const name = form.name.trim()
t: language.t, const baseURL = form.baseURL.trim()
disabledProviders: globalSync.data.config.disabled_providers ?? [], const apiKey = form.apiKey.trim()
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
const key = apiKey && !env ? apiKey : undefined
const idError = !providerID
? language.t("provider.custom.error.providerID.required")
: !PROVIDER_ID.test(providerID)
? language.t("provider.custom.error.providerID.format")
: undefined
const nameError = !name ? language.t("provider.custom.error.name.required") : undefined
const urlError = !baseURL
? language.t("provider.custom.error.baseURL.required")
: !/^https?:\/\//.test(baseURL)
? language.t("provider.custom.error.baseURL.format")
: undefined
const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID)
const existingProvider = globalSync.data.provider.all.find((p) => p.id === providerID)
const existsError = idError
? undefined
: existingProvider && !disabled
? language.t("provider.custom.error.providerID.exists")
: undefined
const seenModels = new Set<string>()
const modelErrors = form.models.map((m) => {
const id = m.id.trim()
const modelIdError = !id
? language.t("provider.custom.error.required")
: seenModels.has(id)
? language.t("provider.custom.error.duplicate")
: (() => {
seenModels.add(id)
return undefined
})()
const modelNameError = !m.name.trim() ? language.t("provider.custom.error.required") : undefined
return { id: modelIdError, name: modelNameError }
}) })
setErrors(output.errors) const modelsValid = modelErrors.every((m) => !m.id && !m.name)
return output.result const models = Object.fromEntries(form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
const seenHeaders = new Set<string>()
const headerErrors = form.headers.map((h) => {
const key = h.key.trim()
const value = h.value.trim()
if (!key && !value) return {}
const keyError = !key
? language.t("provider.custom.error.required")
: seenHeaders.has(key.toLowerCase())
? language.t("provider.custom.error.duplicate")
: (() => {
seenHeaders.add(key.toLowerCase())
return undefined
})()
const valueError = !value ? language.t("provider.custom.error.required") : undefined
return { key: keyError, value: valueError }
})
const headersValid = headerErrors.every((h) => !h.key && !h.value)
const headers = Object.fromEntries(
form.headers
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
.filter((h) => !!h.key && !!h.value)
.map((h) => [h.key, h.value]),
)
setErrors(
produce((draft) => {
draft.providerID = idError ?? existsError
draft.name = nameError
draft.baseURL = urlError
draft.models = modelErrors
draft.headers = headerErrors
}),
)
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
if (!ok) return
const options = {
baseURL,
...(Object.keys(headers).length ? { headers } : {}),
}
return {
providerID,
name,
key,
config: {
npm: OPENAI_COMPATIBLE,
name,
...(env ? { env: [env] } : {}),
options,
models,
},
}
} }
const save = async (e: SubmitEvent) => { const save = async (e: SubmitEvent) => {
@@ -305,7 +297,7 @@ export function DialogCustomProvider(props: Props) {
placeholder={language.t("provider.custom.field.providerID.placeholder")} placeholder={language.t("provider.custom.field.providerID.placeholder")}
description={language.t("provider.custom.field.providerID.description")} description={language.t("provider.custom.field.providerID.description")}
value={form.providerID} value={form.providerID}
onChange={(v) => setForm("providerID", v)} onChange={setForm.bind(null, "providerID")}
validationState={errors.providerID ? "invalid" : undefined} validationState={errors.providerID ? "invalid" : undefined}
error={errors.providerID} error={errors.providerID}
/> />
@@ -313,7 +305,7 @@ export function DialogCustomProvider(props: Props) {
label={language.t("provider.custom.field.name.label")} label={language.t("provider.custom.field.name.label")}
placeholder={language.t("provider.custom.field.name.placeholder")} placeholder={language.t("provider.custom.field.name.placeholder")}
value={form.name} value={form.name}
onChange={(v) => setForm("name", v)} onChange={setForm.bind(null, "name")}
validationState={errors.name ? "invalid" : undefined} validationState={errors.name ? "invalid" : undefined}
error={errors.name} error={errors.name}
/> />
@@ -321,7 +313,7 @@ export function DialogCustomProvider(props: Props) {
label={language.t("provider.custom.field.baseURL.label")} label={language.t("provider.custom.field.baseURL.label")}
placeholder={language.t("provider.custom.field.baseURL.placeholder")} placeholder={language.t("provider.custom.field.baseURL.placeholder")}
value={form.baseURL} value={form.baseURL}
onChange={(v) => setForm("baseURL", v)} onChange={setForm.bind(null, "baseURL")}
validationState={errors.baseURL ? "invalid" : undefined} validationState={errors.baseURL ? "invalid" : undefined}
error={errors.baseURL} error={errors.baseURL}
/> />
@@ -330,7 +322,7 @@ export function DialogCustomProvider(props: Props) {
placeholder={language.t("provider.custom.field.apiKey.placeholder")} placeholder={language.t("provider.custom.field.apiKey.placeholder")}
description={language.t("provider.custom.field.apiKey.description")} description={language.t("provider.custom.field.apiKey.description")}
value={form.apiKey} value={form.apiKey}
onChange={(v) => setForm("apiKey", v)} onChange={setForm.bind(null, "apiKey")}
/> />
</div> </div>
@@ -33,8 +33,6 @@ export function DialogEditProject(props: { project: LocalProject }) {
iconHover: false, iconHover: false,
}) })
let iconInput: HTMLInputElement | undefined
function handleFileSelect(file: File) { function handleFileSelect(file: File) {
if (!file.type.startsWith("image/")) return if (!file.type.startsWith("image/")) return
const reader = new FileReader() const reader = new FileReader()
@@ -74,35 +72,31 @@ export function DialogEditProject(props: { project: LocalProject }) {
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: SubmitEvent) {
e.preventDefault() e.preventDefault()
await Promise.resolve() setStore("saving", true)
.then(async () => { const name = store.name.trim() === folderName() ? "" : store.name.trim()
setStore("saving", true) const start = store.startup.trim()
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") { if (props.project.id && props.project.id !== "global") {
await globalSDK.client.project.update({ await globalSDK.client.project.update({
projectID: props.project.id, projectID: props.project.id,
directory: props.project.worktree, directory: props.project.worktree,
name, name,
icon: { color: store.color, override: store.iconUrl }, icon: { color: store.color, override: store.iconUrl },
commands: { start }, commands: { start },
}) })
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined) globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
dialog.close() setStore("saving", false)
return dialog.close()
} return
}
globalSync.project.meta(props.project.worktree, { globalSync.project.meta(props.project.worktree, {
name, name,
icon: { color: store.color, override: store.iconUrl || undefined }, icon: { color: store.color, override: store.iconUrl || undefined },
commands: { start: start || undefined }, commands: { start: start || undefined },
}) })
dialog.close() setStore("saving", false)
}) dialog.close()
.finally(() => {
setStore("saving", false)
})
} }
return ( return (
@@ -140,7 +134,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
if (store.iconUrl && store.iconHover) { if (store.iconUrl && store.iconHover) {
clearIcon() clearIcon()
} else { } else {
iconInput?.click() document.getElementById("icon-upload")?.click()
} }
}} }}
> >
@@ -182,16 +176,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" /> <Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
</div> </div>
</div> </div>
<input <input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
id="icon-upload"
ref={(el) => {
iconInput = el
}}
type="file"
accept="image/*"
class="hidden"
onChange={handleInputChange}
/>
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center"> <div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
<span>{language.t("dialog.project.edit.icon.hint")}</span> <span>{language.t("dialog.project.edit.icon.hint")}</span>
<span>{language.t("dialog.project.edit.icon.recommended")}</span> <span>{language.t("dialog.project.edit.icon.recommended")}</span>
+8 -17
View File
@@ -6,7 +6,6 @@ import { usePrompt } from "@/context/prompt"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { showToast } from "@opencode-ai/ui/toast"
import { extractPromptFromParts } from "@/utils/prompt" import { extractPromptFromParts } from "@/utils/prompt"
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client" import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
@@ -67,23 +66,15 @@ export const DialogFork: Component = () => {
attachmentName: language.t("common.attachment"), attachmentName: language.t("common.attachment"),
}) })
sdk.client.session dialog.close()
.fork({ sessionID, messageID: item.id })
.then((forked) => { sdk.client.session.fork({ sessionID, messageID: item.id }).then((forked) => {
if (!forked.data) { if (!forked.data) return
showToast({ title: language.t("common.requestFailed") }) navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
return requestAnimationFrame(() => {
} prompt.set(restored)
dialog.close()
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
requestAnimationFrame(() => {
prompt.set(restored)
})
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
}) })
})
} }
return ( return (
@@ -1,7 +1,6 @@
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import type { Component } from "solid-js" import type { Component } from "solid-js"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
@@ -18,15 +17,6 @@ export const DialogManageModels: Component = () => {
const handleConnectProvider = () => { const handleConnectProvider = () => {
dialog.show(() => <DialogSelectProvider />) 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 ( return (
<Dialog <Dialog
@@ -45,41 +35,21 @@ export const DialogManageModels: Component = () => {
items={local.model.list()} items={local.model.list()}
filterKeys={["provider.name", "name", "id"]} filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)} sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.id} groupBy={(x) => x.provider.name}
groupHeader={(group) => {
const provider = group.items[0].provider
return (
<>
<span>{provider.name}</span>
<Tooltip
placement="top"
value={language.t("dialog.model.manage.provider.toggle", { provider: provider.name })}
>
<Switch
class="-mr-1"
checked={providerVisible(provider.id)}
onChange={(checked) => setProviderVisibility(provider.id, checked)}
hideLabel
>
{provider.name}
</Switch>
</Tooltip>
</>
)
}}
sortGroupsBy={(a, b) => { sortGroupsBy={(a, b) => {
const aRank = providerRank(a.items[0].provider.id) const aProvider = a.items[0].provider.id
const bRank = providerRank(b.items[0].provider.id) const bProvider = b.items[0].provider.id
const aPopular = aRank >= 0 if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
const bPopular = bRank >= 0 if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
if (aPopular && !bPopular) return -1 return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
if (!aPopular && bPopular) return 1
return aRank - bRank
}} }}
onSelect={(x) => { onSelect={(x) => {
if (!x) return if (!x) return
const key = { modelID: x.id, providerID: x.provider.id } const visible = local.model.visible({
local.model.setVisibility(key, !local.model.visible(key)) modelID: x.id,
providerID: x.provider.id,
})
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible)
}} }}
> >
{(i) => ( {(i) => (
@@ -87,7 +57,12 @@ export const DialogManageModels: Component = () => {
<span>{i.name}</span> <span>{i.name}</span>
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>
<Switch <Switch
checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })} checked={
!!local.model.visible({
modelID: i.id,
providerID: i.provider.id,
})
}
onChange={(checked) => { onChange={(checked) => {
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked) local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
}} }}
@@ -1,4 +1,4 @@
import { createSignal } from "solid-js" import { createSignal, createEffect, onMount, onCleanup } from "solid-js"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -40,6 +40,8 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
handleClose() handleClose()
} }
let focusTrap: HTMLDivElement | undefined
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") { if (e.key === "Escape") {
e.preventDefault() e.preventDefault()
@@ -58,13 +60,27 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
} }
} }
onMount(() => {
focusTrap?.focus()
document.addEventListener("keydown", handleKeyDown)
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
})
// Refocus the trap when index changes to ensure escape always works
createEffect(() => {
index() // track index
focusTrap?.focus()
})
return ( return (
<Dialog <Dialog
size="large" size="large"
fit fit
class="w-[min(calc(100vw-40px),720px)] h-[min(calc(100vh-40px),400px)] -mt-20 min-h-0 overflow-hidden" class="w-[min(calc(100vw-40px),720px)] h-[min(calc(100vh-40px),400px)] -mt-20 min-h-0 overflow-hidden"
> >
<div class="flex flex-1 min-w-0 min-h-0" tabIndex={0} autofocus onKeyDown={handleKeyDown}> {/* Hidden element to capture initial focus and handle escape */}
<div ref={focusTrap} tabindex="0" class="absolute opacity-0 pointer-events-none" />
<div class="flex flex-1 min-w-0 min-h-0">
{/* Left side - Text content */} {/* Left side - Text content */}
<div class="flex flex-col flex-1 min-w-0 p-8"> <div class="flex flex-col flex-1 min-w-0 p-8">
{/* Top section - feature content (fixed position from top) */} {/* Top section - feature content (fixed position from top) */}
@@ -2,13 +2,13 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import type { ListRef } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/util/path" import { getDirectory, getFilename } from "@opencode-ai/util/path"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
import { createMemo, createResource, createSignal } from "solid-js" import { createMemo, createResource, createSignal } from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import type { ListRef } from "@opencode-ai/ui/list"
interface DialogSelectDirectoryProps { interface DialogSelectDirectoryProps {
title?: string title?: string
@@ -21,131 +21,157 @@ type Row = {
search: string search: string
} }
function cleanInput(value: string) { export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const first = (value ?? "").split(/\r?\n/)[0] ?? "" const sync = useGlobalSync()
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim() const sdk = useGlobalSDK()
} const dialog = useDialog()
const language = useLanguage()
function normalizePath(input: string) { const [filter, setFilter] = createSignal("")
const v = input.replaceAll("\\", "/")
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
return v.replace(/\/+/g, "/")
}
function normalizeDriveRoot(input: string) { let list: ListRef | undefined
const v = normalizePath(input)
if (/^[A-Za-z]:$/.test(v)) return v + "/"
return v
}
function trimTrailing(input: string) { const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const v = normalizeDriveRoot(input)
if (v === "/") return v
if (v === "//") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
return v.replace(/\/+$/, "")
}
function joinPath(base: string | undefined, rel: string) { const [fallbackPath] = createResource(
const b = trimTrailing(base ?? "") () => (missingBase() ? true : undefined),
const r = trimTrailing(rel).replace(/^\/+/, "") async () => {
if (!b) return r return sdk.client.path
if (!r) return b .get()
if (b.endsWith("/")) return b + r .then((x) => x.data)
return b + "/" + r .catch(() => undefined)
} },
{ initialValue: undefined },
)
function rootOf(input: string) { const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
const v = normalizeDriveRoot(input)
if (v.startsWith("//")) return "//"
if (v.startsWith("/")) return "/"
if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
return ""
}
function parentOf(input: string) { const start = createMemo(
const v = trimTrailing(input) () => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
if (v === "/") return v )
if (v === "//") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
const i = v.lastIndexOf("/") const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
if (i <= 0) return "/"
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
return v.slice(0, i)
}
function modeOf(input: string) { const clean = (value: string) => {
const raw = normalizeDriveRoot(input.trim()) const first = (value ?? "").split(/\r?\n/)[0] ?? ""
if (!raw) return "relative" as const return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
if (raw.startsWith("~")) return "tilde" as const
if (rootOf(raw)) return "absolute" as const
return "relative" as const
}
function tildeOf(absolute: string, home: string) {
const full = trimTrailing(absolute)
if (!home) return ""
const hn = trimTrailing(home)
const lc = full.toLowerCase()
const hc = hn.toLowerCase()
if (lc === hc) return "~"
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
return ""
}
function displayPath(path: string, input: string, home: string) {
const full = trimTrailing(path)
if (modeOf(input) === "absolute") return full
return tildeOf(full, home) || full
}
function toRow(absolute: string, home: string): Row {
const full = trimTrailing(absolute)
const tilde = tildeOf(full, home)
const withSlash = (value: string) => {
if (!value) return ""
if (value.endsWith("/")) return value
return value + "/"
} }
const search = Array.from( function normalize(input: string) {
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)), const v = input.replaceAll("\\", "/")
).join("\n") if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
return { absolute: full, search } return v.replace(/\/+/g, "/")
} }
function useDirectorySearch(args: { function normalizeDriveRoot(input: string) {
sdk: ReturnType<typeof useGlobalSDK> const v = normalize(input)
start: () => string | undefined if (/^[A-Za-z]:$/.test(v)) return v + "/"
home: () => string return v
}) { }
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
let current = 0
const scoped = (value: string) => { function trimTrailing(input: string) {
const base = args.start() const v = normalizeDriveRoot(input)
if (v === "/") return v
if (v === "//") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
return v.replace(/\/+$/, "")
}
function join(base: string | undefined, rel: string) {
const b = trimTrailing(base ?? "")
const r = trimTrailing(rel).replace(/^\/+/, "")
if (!b) return r
if (!r) return b
if (b.endsWith("/")) return b + r
return b + "/" + r
}
function rootOf(input: string) {
const v = normalizeDriveRoot(input)
if (v.startsWith("//")) return "//"
if (v.startsWith("/")) return "/"
if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
return ""
}
function parentOf(input: string) {
const v = trimTrailing(input)
if (v === "/") return v
if (v === "//") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
const i = v.lastIndexOf("/")
if (i <= 0) return "/"
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
return v.slice(0, i)
}
function modeOf(input: string) {
const raw = normalizeDriveRoot(input.trim())
if (!raw) return "relative" as const
if (raw.startsWith("~")) return "tilde" as const
if (rootOf(raw)) return "absolute" as const
return "relative" as const
}
function display(path: string, input: string) {
const full = trimTrailing(path)
if (modeOf(input) === "absolute") return full
return tildeOf(full) || full
}
function tildeOf(absolute: string) {
const full = trimTrailing(absolute)
const h = home()
if (!h) return ""
const hn = trimTrailing(h)
const lc = full.toLowerCase()
const hc = hn.toLowerCase()
if (lc === hc) return "~"
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
return ""
}
function row(absolute: string): Row {
const full = trimTrailing(absolute)
const tilde = tildeOf(full)
const withSlash = (value: string) => {
if (!value) return ""
if (value.endsWith("/")) return value
return value + "/"
}
const search = Array.from(
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
).join("\n")
return { absolute: full, search }
}
function scoped(value: string) {
const base = start()
if (!base) return if (!base) return
const raw = normalizeDriveRoot(value) const raw = normalizeDriveRoot(value)
if (!raw) return { directory: trimTrailing(base), path: "" } if (!raw) return { directory: trimTrailing(base), path: "" }
const h = args.home() const h = home()
if (raw === "~") return { directory: trimTrailing(h || base), path: "" } if (raw === "~") return { directory: trimTrailing(h ?? base), path: "" }
if (raw.startsWith("~/")) return { directory: trimTrailing(h || base), path: raw.slice(2) } if (raw.startsWith("~/")) return { directory: trimTrailing(h ?? base), path: raw.slice(2) }
const root = rootOf(raw) const root = rootOf(raw)
if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) } if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) }
return { directory: trimTrailing(base), path: raw } return { directory: trimTrailing(base), path: raw }
} }
const dirs = async (dir: string) => { async function dirs(dir: string) {
const key = trimTrailing(dir) const key = trimTrailing(dir)
const existing = cache.get(key) const existing = cache.get(key)
if (existing) return existing if (existing) return existing
const request = args.sdk.client.file const request = sdk.client.file
.list({ directory: key, path: "" }) .list({ directory: key, path: "" })
.then((x) => x.data ?? []) .then((x) => x.data ?? [])
.catch(() => []) .catch(() => [])
@@ -162,34 +188,32 @@ function useDirectorySearch(args: {
return request return request
} }
const match = async (dir: string, query: string, limit: number) => { async function match(dir: string, query: string, limit: number) {
const items = await dirs(dir) const items = await dirs(dir)
if (!query) return items.slice(0, limit).map((x) => x.absolute) if (!query) return items.slice(0, limit).map((x) => x.absolute)
return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute) return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute)
} }
return async (filter: string) => { const directories = async (filter: string) => {
const token = ++current const value = clean(filter)
const active = () => token === current
const value = cleanInput(filter)
const scopedInput = scoped(value) const scopedInput = scoped(value)
if (!scopedInput) return [] as string[] if (!scopedInput) return [] as string[]
const raw = normalizeDriveRoot(value) const raw = normalizeDriveRoot(value)
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/") const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
const query = normalizeDriveRoot(scopedInput.path) const query = normalizeDriveRoot(scopedInput.path)
const find = () => const find = () =>
args.sdk.client.find sdk.client.find
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 }) .files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
.then((x) => x.data ?? []) .then((x) => x.data ?? [])
.catch(() => []) .catch(() => [])
if (!isPath) { if (!isPath) {
const results = await find() const results = await find()
if (!active()) return []
return results.map((rel) => joinPath(scopedInput.directory, rel)).slice(0, 50) return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50)
} }
const segments = query.replace(/^\/+/, "").split("/") const segments = query.replace(/^\/+/, "").split("/")
@@ -200,20 +224,17 @@ function useDirectorySearch(args: {
const branch = 4 const branch = 4
let paths = [scopedInput.directory] let paths = [scopedInput.directory]
for (const part of head) { for (const part of head) {
if (!active()) return []
if (part === "..") { if (part === "..") {
paths = paths.map(parentOf) paths = paths.map(parentOf)
continue continue
} }
const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat() const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat()
if (!active()) return []
paths = Array.from(new Set(next)).slice(0, cap) paths = Array.from(new Set(next)).slice(0, cap)
if (paths.length === 0) return [] as string[] if (paths.length === 0) return [] as string[]
} }
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat() const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
if (!active()) return []
const deduped = Array.from(new Set(out)) const deduped = Array.from(new Set(out))
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : "" const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
const expand = !raw.endsWith("/") const expand = !raw.endsWith("/")
@@ -228,47 +249,13 @@ function useDirectorySearch(args: {
if (!target) return deduped.slice(0, 50) if (!target) return deduped.slice(0, 50)
const children = await match(target, "", 30) const children = await match(target, "", 30)
if (!active()) return []
const items = Array.from(new Set([...deduped, ...children])) const items = Array.from(new Set([...deduped, ...children]))
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50) return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
} }
}
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const sync = useGlobalSync()
const sdk = useGlobalSDK()
const dialog = useDialog()
const language = useLanguage()
const [filter, setFilter] = createSignal("")
let list: ListRef | undefined
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
async () => {
return sdk.client.path
.get()
.then((x) => x.data)
.catch(() => undefined)
},
{ initialValue: undefined },
)
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
const start = createMemo(
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
)
const directories = useDirectorySearch({
sdk,
home,
start,
})
const items = async (value: string) => { const items = async (value: string) => {
const results = await directories(value) const results = await directories(value)
return results.map((absolute) => toRow(absolute, home())) return results.map(row)
} }
function resolve(absolute: string) { function resolve(absolute: string) {
@@ -286,7 +273,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
key={(x) => x.absolute} key={(x) => x.absolute}
filterKeys={["search"]} filterKeys={["search"]}
ref={(r) => (list = r)} ref={(r) => (list = r)}
onFilter={(value) => setFilter(cleanInput(value))} onFilter={(value) => setFilter(clean(value))}
onKeyEvent={(e, item) => { onKeyEvent={(e, item) => {
if (e.key !== "Tab") return if (e.key !== "Tab") return
if (e.shiftKey) return if (e.shiftKey) return
@@ -295,7 +282,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
const value = displayPath(item.absolute, filter(), home()) const value = display(item.absolute, filter())
list?.setFilter(value.endsWith("/") ? value : value + "/") list?.setFilter(value.endsWith("/") ? value : value + "/")
}} }}
onSelect={(path) => { onSelect={(path) => {
@@ -304,7 +291,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
}} }}
> >
{(item) => { {(item) => {
const path = displayPath(item.absolute, filter(), home()) const path = display(item.absolute, filter())
if (path === "~") { if (path === "~") {
return ( return (
<div class="w-full flex items-center justify-between rounded-md"> <div class="w-full flex items-center justify-between rounded-md">
+171 -229
View File
@@ -36,223 +36,6 @@ type Entry = {
type DialogSelectFileMode = "all" | "files" type DialogSelectFileMode = "all" | "files"
const ENTRY_LIMIT = 5
const COMMON_COMMAND_IDS = [
"session.new",
"workspace.new",
"session.previous",
"session.next",
"terminal.toggle",
"review.toggle",
] as const
const uniqueEntries = (items: Entry[]) => {
const seen = new Set<string>()
const out: Entry[] = []
for (const item of items) {
if (seen.has(item.id)) continue
seen.add(item.id)
out.push(item)
}
return out
}
const createCommandEntry = (option: CommandOption, category: string): Entry => ({
id: "command:" + option.id,
type: "command",
title: option.title,
description: option.description,
keybind: option.keybind,
category,
option,
})
const createFileEntry = (path: string, category: string): Entry => ({
id: "file:" + path,
type: "file",
title: path,
category,
path,
})
const createSessionEntry = (
input: {
directory: string
id: string
title: string
description: string
archived?: number
updated?: number
},
category: string,
): Entry => ({
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category,
directory: input.directory,
sessionID: input.id,
archived: input.archived,
updated: input.updated,
})
function createCommandEntries(props: {
filesOnly: () => boolean
command: ReturnType<typeof useCommand>
language: ReturnType<typeof useLanguage>
}) {
const allowed = createMemo(() => {
if (props.filesOnly()) return []
return props.command.options.filter(
(option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
})
const list = createMemo(() => {
const category = props.language.t("palette.group.commands")
return allowed().map((option) => createCommandEntry(option, category))
})
const picks = createMemo(() => {
const all = allowed()
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
const picked = all.filter((option) => order.has(option.id))
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
const category = props.language.t("palette.group.commands")
return sorted.map((option) => createCommandEntry(option, category))
})
return { allowed, list, picks }
}
function createFileEntries(props: {
file: ReturnType<typeof useFile>
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
language: ReturnType<typeof useLanguage>
}) {
const recent = createMemo(() => {
const all = props.tabs().all()
const active = props.tabs().active()
const order = active ? [active, ...all.filter((item) => item !== active)] : all
const seen = new Set<string>()
const category = props.language.t("palette.group.files")
const items: Entry[] = []
for (const item of order) {
const path = props.file.pathFromTab(item)
if (!path) continue
if (seen.has(path)) continue
seen.add(path)
items.push(createFileEntry(path, category))
}
return items.slice(0, ENTRY_LIMIT)
})
const root = createMemo(() => {
const category = props.language.t("palette.group.files")
const nodes = props.file.tree.children("")
const paths = nodes
.filter((node) => node.type === "file")
.map((node) => node.path)
.sort((a, b) => a.localeCompare(b))
return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category))
})
return { recent, root }
}
function createSessionEntries(props: {
workspaces: () => string[]
label: (directory: string) => string
globalSDK: ReturnType<typeof useGlobalSDK>
language: ReturnType<typeof useLanguage>
}) {
const state: {
token: number
inflight: Promise<Entry[]> | undefined
cached: Entry[] | undefined
} = {
token: 0,
inflight: undefined,
cached: undefined,
}
const sessions = (text: string) => {
const query = text.trim()
if (!query) {
state.token += 1
state.inflight = undefined
state.cached = undefined
return [] as Entry[]
}
if (state.cached) return state.cached
if (state.inflight) return state.inflight
const current = state.token
const dirs = props.workspaces()
if (dirs.length === 0) return [] as Entry[]
state.inflight = Promise.all(
dirs.map((directory) => {
const description = props.label(directory)
return props.globalSDK.client.session
.list({ directory, roots: true })
.then((x) =>
(x.data ?? [])
.filter((s) => !!s?.id)
.map((s) => ({
id: s.id,
title: s.title ?? props.language.t("command.session.new"),
description,
directory,
archived: s.time?.archived,
updated: s.time?.updated,
})),
)
.catch(
() =>
[] as {
id: string
title: string
description: string
directory: string
archived?: number
updated?: number
}[],
)
}),
)
.then((results) => {
if (state.token !== current) return [] as Entry[]
const seen = new Set<string>()
const category = props.language.t("command.category.session")
const next = results
.flat()
.filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map((item) => createSessionEntry(item, category))
state.cached = next
return next
})
.catch(() => [] as Entry[])
.finally(() => {
state.inflight = undefined
})
return state.inflight
}
return { sessions }
}
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
const command = useCommand() const command = useCommand()
const language = useLanguage() const language = useLanguage()
@@ -269,8 +52,40 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const view = createMemo(() => layout.view(sessionKey)) const view = createMemo(() => layout.view(sessionKey))
const state = { cleanup: undefined as (() => void) | void, committed: false } const state = { cleanup: undefined as (() => void) | void, committed: false }
const [grouped, setGrouped] = createSignal(false) const [grouped, setGrouped] = createSignal(false)
const commandEntries = createCommandEntries({ filesOnly, command, language }) const common = [
const fileEntries = createFileEntries({ file, tabs, language }) "session.new",
"workspace.new",
"session.previous",
"session.next",
"terminal.toggle",
"review.toggle",
]
const limit = 5
const allowed = createMemo(() => {
if (filesOnly()) return []
return command.options.filter(
(option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
})
const commandItem = (option: CommandOption): Entry => ({
id: "command:" + option.id,
type: "command",
title: option.title,
description: option.description,
keybind: option.keybind,
category: language.t("palette.group.commands"),
option,
})
const fileItem = (path: string): Entry => ({
id: "file:" + path,
type: "file",
title: path,
category: language.t("palette.group.files"),
path,
})
const projectDirectory = createMemo(() => decode64(params.dir) ?? "") const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => { const project = createMemo(() => {
@@ -301,7 +116,136 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
return `${kind} : ${name || path}` return `${kind} : ${name || path}`
} }
const { sessions } = createSessionEntries({ workspaces, label, globalSDK, language }) const sessionItem = (input: {
directory: string
id: string
title: string
description: string
archived?: number
updated?: number
}): Entry => ({
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category: language.t("command.category.session"),
directory: input.directory,
sessionID: input.id,
archived: input.archived,
updated: input.updated,
})
const list = createMemo(() => allowed().map(commandItem))
const picks = createMemo(() => {
const all = allowed()
const order = new Map(common.map((id, index) => [id, index]))
const picked = all.filter((option) => order.has(option.id))
const base = picked.length ? picked : all.slice(0, limit)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
return sorted.map(commandItem)
})
const recent = createMemo(() => {
const all = tabs().all()
const active = tabs().active()
const order = active ? [active, ...all.filter((item) => item !== active)] : all
const seen = new Set<string>()
const items: Entry[] = []
for (const item of order) {
const path = file.pathFromTab(item)
if (!path) continue
if (seen.has(path)) continue
seen.add(path)
items.push(fileItem(path))
}
return items.slice(0, limit)
})
const root = createMemo(() => {
const nodes = file.tree.children("")
const paths = nodes
.filter((node) => node.type === "file")
.map((node) => node.path)
.sort((a, b) => a.localeCompare(b))
return paths.slice(0, limit).map(fileItem)
})
const unique = (items: Entry[]) => {
const seen = new Set<string>()
const out: Entry[] = []
for (const item of items) {
if (seen.has(item.id)) continue
seen.add(item.id)
out.push(item)
}
return out
}
const sessionToken = { value: 0 }
let sessionInflight: Promise<Entry[]> | undefined
let sessionAll: Entry[] | undefined
const sessions = (text: string) => {
const query = text.trim()
if (!query) {
sessionToken.value += 1
sessionInflight = undefined
sessionAll = undefined
return [] as Entry[]
}
if (sessionAll) return sessionAll
if (sessionInflight) return sessionInflight
const current = sessionToken.value
const dirs = workspaces()
if (dirs.length === 0) return [] as Entry[]
sessionInflight = Promise.all(
dirs.map((directory) => {
const description = label(directory)
return globalSDK.client.session
.list({ directory, roots: true })
.then((x) =>
(x.data ?? [])
.filter((s) => !!s?.id)
.map((s) => ({
id: s.id,
title: s.title ?? language.t("command.session.new"),
description,
directory,
archived: s.time?.archived,
updated: s.time?.updated,
})),
)
.catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[])
}),
)
.then((results) => {
if (sessionToken.value !== current) return [] as Entry[]
const seen = new Set<string>()
const next = results
.flat()
.filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map(sessionItem)
sessionAll = next
return next
})
.catch(() => [] as Entry[])
.finally(() => {
sessionInflight = undefined
})
return sessionInflight
}
const items = async (text: string) => { const items = async (text: string) => {
const query = text.trim() const query = text.trim()
@@ -310,7 +254,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
if (!query && filesOnly()) { if (!query && filesOnly()) {
const loaded = file.tree.state("")?.loaded const loaded = file.tree.state("")?.loaded
const pending = loaded ? Promise.resolve() : file.tree.list("") const pending = loaded ? Promise.resolve() : file.tree.list("")
const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()]) const next = unique([...recent(), ...root()])
if (loaded || next.length > 0) { if (loaded || next.length > 0) {
void pending void pending
@@ -318,21 +262,19 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
} }
await pending await pending
return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()]) return unique([...recent(), ...root()])
} }
if (!query) return [...commandEntries.picks(), ...fileEntries.recent()] if (!query) return [...picks(), ...recent()]
if (filesOnly()) { if (filesOnly()) {
const files = await file.searchFiles(query) const files = await file.searchFiles(query)
const category = language.t("palette.group.files") return files.map(fileItem)
return files.map((path) => createFileEntry(path, category))
} }
const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))]) const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
const category = language.t("palette.group.files") const entries = files.map(fileItem)
const entries = files.map((path) => createFileEntry(path, category)) return [...list(), ...nextSessions, ...entries]
return [...commandEntries.list(), ...nextSessions, ...entries]
} }
const handleMove = (item: Entry | undefined) => { const handleMove = (item: Entry | undefined) => {
@@ -347,9 +289,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
tabs().open(value) tabs().open(value)
file.load(path) file.load(path)
if (!view().reviewPanel.opened()) view().reviewPanel.open() if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.open()
layout.fileTree.setTab("all") layout.fileTree.setTab("all")
props.onOpenFile?.(path) props.onOpenFile?.(path)
tabs().setActive(value)
} }
const handleSelect = (item: Entry | undefined) => { const handleSelect = (item: Entry | undefined) => {
@@ -6,13 +6,6 @@ import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
const statusLabels = {
connected: "mcp.status.connected",
failed: "mcp.status.failed",
needs_auth: "mcp.status.needs_auth",
disabled: "mcp.status.disabled",
} as const
export const DialogSelectMcp: Component = () => { export const DialogSelectMcp: Component = () => {
const sync = useSync() const sync = useSync()
const sdk = useSDK() const sdk = useSDK()
@@ -28,19 +21,15 @@ export const DialogSelectMcp: Component = () => {
const toggle = async (name: string) => { const toggle = async (name: string) => {
if (loading()) return if (loading()) return
setLoading(name) setLoading(name)
try { const status = sync.data.mcp[name]
const status = sync.data.mcp[name] if (status?.status === "connected") {
if (status?.status === "connected") { await sdk.client.mcp.disconnect({ name })
await sdk.client.mcp.disconnect({ name }) } else {
} else { await sdk.client.mcp.connect({ name })
await sdk.client.mcp.connect({ name })
}
const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data)
} finally {
setLoading(null)
} }
const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data)
setLoading(null)
} }
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
@@ -65,11 +54,6 @@ export const DialogSelectMcp: Component = () => {
{(i) => { {(i) => {
const mcpStatus = () => sync.data.mcp[i.name] const mcpStatus = () => sync.data.mcp[i.name]
const status = () => mcpStatus()?.status const status = () => mcpStatus()?.status
const statusLabel = () => {
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
if (!key) return
return language.t(key)
}
const error = () => { const error = () => {
const s = mcpStatus() const s = mcpStatus()
return s?.status === "failed" ? s.error : undefined return s?.status === "failed" ? s.error : undefined
@@ -80,8 +64,17 @@ export const DialogSelectMcp: Component = () => {
<div class="flex flex-col gap-0.5 min-w-0"> <div class="flex flex-col gap-0.5 min-w-0">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="truncate">{i.name}</span> <span class="truncate">{i.name}</span>
<Show when={statusLabel()}> <Show when={status() === "connected"}>
<span class="text-11-regular text-text-weaker">{statusLabel()}</span> <span class="text-11-regular text-text-weaker">{language.t("mcp.status.connected")}</span>
</Show>
<Show when={status() === "failed"}>
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.failed")}</span>
</Show>
<Show when={status() === "needs_auth"}>
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.needs_auth")}</span>
</Show>
<Show when={status() === "disabled"}>
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.disabled")}</span>
</Show> </Show>
<Show when={loading() === i.name}> <Show when={loading() === i.name}>
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span> <span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
@@ -6,7 +6,7 @@ import { List, type ListRef } from "@opencode-ai/ui/list"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tag } from "@opencode-ai/ui/tag" import { Tag } from "@opencode-ai/ui/tag"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { type Component, Show } from "solid-js" import { type Component, onCleanup, onMount, Show } from "solid-js"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { popularProviders, useProviders } from "@/hooks/use-providers" import { popularProviders, useProviders } from "@/hooks/use-providers"
import { DialogConnectProvider } from "./dialog-connect-provider" import { DialogConnectProvider } from "./dialog-connect-provider"
@@ -21,17 +21,24 @@ export const DialogSelectModelUnpaid: Component = () => {
const language = useLanguage() const language = useLanguage()
let listRef: ListRef | undefined let listRef: ListRef | undefined
const handleKeyDown = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") return if (e.key === "Escape") return
listRef?.onKeyDown(e) listRef?.onKeyDown(e)
} }
onMount(() => {
document.addEventListener("keydown", handleKey)
onCleanup(() => {
document.removeEventListener("keydown", handleKey)
})
})
return ( return (
<Dialog <Dialog
title={language.t("dialog.model.select.title")} title={language.t("dialog.model.select.title")}
class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none" class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none"
> >
<div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}> <div class="flex flex-col gap-3 px-2.5">
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div> <div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
<List <List
class="[&_[data-slot=list-scroll]]:overflow-visible" class="[&_[data-slot=list-scroll]]:overflow-visible"
@@ -1,5 +1,5 @@
import { Popover as Kobalte } from "@kobalte/core/popover" import { Popover as Kobalte } from "@kobalte/core/popover"
import { Component, ComponentProps, createMemo, JSX, Show, ValidComponent } from "solid-js" import { Component, ComponentProps, createEffect, createMemo, JSX, onCleanup, Show, ValidComponent } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -15,9 +15,6 @@ import { DialogManageModels } from "./dialog-manage-models"
import { ModelTooltip } from "./model-tooltip" import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
const ModelList: Component<{ const ModelList: Component<{
provider?: string provider?: string
class?: string class?: string
@@ -57,7 +54,13 @@ const ModelList: Component<{
class="w-full" class="w-full"
placement="right-start" placement="right-start"
gutter={12} gutter={12}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />} value={
<ModelTooltip
model={item}
latest={item.latest}
free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)}
/>
}
> >
{node} {node}
</Tooltip> </Tooltip>
@@ -72,7 +75,7 @@ const ModelList: Component<{
{(i) => ( {(i) => (
<div class="w-full flex items-center gap-x-2 text-13-regular"> <div class="w-full flex items-center gap-x-2 text-13-regular">
<span class="truncate">{i.name}</span> <span class="truncate">{i.name}</span>
<Show when={isFree(i.provider.id, i.cost)}> <Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
<Tag>{language.t("model.tag.free")}</Tag> <Tag>{language.t("model.tag.free")}</Tag>
</Show> </Show>
<Show when={i.latest}> <Show when={i.latest}>
@@ -95,9 +98,13 @@ export function ModelSelectorPopover(props: {
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
open: boolean open: boolean
dismiss: "escape" | "outside" | null dismiss: "escape" | "outside" | null
trigger?: HTMLElement
content?: HTMLElement
}>({ }>({
open: false, open: false,
dismiss: null, dismiss: null,
trigger: undefined,
content: undefined,
}) })
const dialog = useDialog() const dialog = useDialog()
@@ -112,6 +119,54 @@ export function ModelSelectorPopover(props: {
} }
const language = useLanguage() const language = useLanguage()
createEffect(() => {
if (!store.open) return
const inside = (node: Node | null | undefined) => {
if (!node) return false
const el = store.content
if (el && el.contains(node)) return true
const anchor = store.trigger
if (anchor && anchor.contains(node)) return true
return false
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return
setStore("dismiss", "escape")
setStore("open", false)
event.preventDefault()
event.stopPropagation()
}
const onPointerDown = (event: PointerEvent) => {
const target = event.target
if (!(target instanceof Node)) return
if (inside(target)) return
setStore("dismiss", "outside")
setStore("open", false)
}
const onFocusIn = (event: FocusEvent) => {
if (!store.content) return
const target = event.target
if (!(target instanceof Node)) return
if (inside(target)) return
setStore("dismiss", "outside")
setStore("open", false)
}
window.addEventListener("keydown", onKeyDown, true)
window.addEventListener("pointerdown", onPointerDown, true)
window.addEventListener("focusin", onFocusIn, true)
onCleanup(() => {
window.removeEventListener("keydown", onKeyDown, true)
window.removeEventListener("pointerdown", onPointerDown, true)
window.removeEventListener("focusin", onFocusIn, true)
})
})
return ( return (
<Kobalte <Kobalte
open={store.open} open={store.open}
@@ -123,11 +178,12 @@ export function ModelSelectorPopover(props: {
placement="top-start" placement="top-start"
gutter={8} gutter={8}
> >
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}> <Kobalte.Trigger ref={(el) => setStore("trigger", el)} as={props.triggerAs ?? "div"} {...props.triggerProps}>
{props.children} {props.children}
</Kobalte.Trigger> </Kobalte.Trigger>
<Kobalte.Portal> <Kobalte.Portal>
<Kobalte.Content <Kobalte.Content
ref={(el) => setStore("content", el)}
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden" class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
onEscapeKeyDown={(event) => { onEscapeKeyDown={(event) => {
setStore("dismiss", "escape") setStore("dismiss", "escape")
@@ -24,12 +24,6 @@ export const DialogSelectProvider: Component = () => {
const popularGroup = () => language.t("dialog.provider.group.popular") const popularGroup = () => language.t("dialog.provider.group.popular")
const otherGroup = () => language.t("dialog.provider.group.other") const otherGroup = () => language.t("dialog.provider.group.other")
const customLabel = () => language.t("settings.providers.tag.custom")
const note = (id: string) => {
if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
if (id === "openai") return language.t("dialog.provider.openai.note")
if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
}
return ( return (
<Dialog title={language.t("command.provider.connect")} transition> <Dialog title={language.t("command.provider.connect")} transition>
@@ -40,7 +34,7 @@ export const DialogSelectProvider: Component = () => {
key={(x) => x?.id} key={(x) => x?.id}
items={() => { items={() => {
language.locale() language.locale()
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all()] return [{ id: CUSTOM_ID, name: "Custom provider" }, ...providers.all()]
}} }}
filterKeys={["id", "name"]} filterKeys={["id", "name"]}
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())} groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
@@ -76,7 +70,15 @@ export const DialogSelectProvider: Component = () => {
<Show when={i.id === "opencode"}> <Show when={i.id === "opencode"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag> <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show> </Show>
<Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show> <Show when={i.id === "anthropic"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
</Show>
<Show when={i.id === "openai"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.openai.note")}</div>
</Show>
<Show when={i.id.startsWith("github-copilot")}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.copilot.note")}</div>
</Show>
</div> </div>
)} )}
</List> </List>
@@ -38,64 +38,6 @@ interface EditRowProps {
onBlur: () => void onBlur: () => void
} }
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
const [defaultUrl, defaultUrlActions] = createResource(
async () => {
try {
const url = await platform.getDefaultServerUrl?.()
if (!url) return null
return normalizeServerUrl(url) ?? null
} catch (err) {
showRequestError(language, err)
return null
}
},
{ initialValue: null },
)
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
const setDefault = async (url: string | null) => {
try {
await platform.setDefaultServerUrl?.(url)
defaultUrlActions.mutate(url)
} catch (err) {
showRequestError(language, err)
}
}
return { defaultUrl, canDefault, setDefault }
}
function useServerPreview(fetcher: typeof fetch) {
const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) return false
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
if (!host) return false
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
return host.includes(".") || host.includes(":")
}
const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
setStatus(undefined)
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
const result = await checkServerHealth(normalized, fetcher)
setStatus(result.healthy)
}
return { previewStatus }
}
function AddRow(props: AddRowProps) { function AddRow(props: AddRowProps) {
return ( return (
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1"> <div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
@@ -173,10 +115,6 @@ export function DialogSelectServer() {
const platform = usePlatform() const platform = usePlatform()
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const language = useLanguage() const language = useLanguage()
const fetcher = platform.fetch ?? globalThis.fetch
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
const { previewStatus } = useServerPreview(fetcher)
let listRoot: HTMLDivElement | undefined
const [store, setStore] = createStore({ const [store, setStore] = createStore({
status: {} as Record<string, ServerHealth | undefined>, status: {} as Record<string, ServerHealth | undefined>,
addServer: { addServer: {
@@ -194,6 +132,43 @@ export function DialogSelectServer() {
status: undefined as boolean | undefined, status: undefined as boolean | undefined,
}, },
}) })
const [defaultUrl, defaultUrlActions] = createResource(
async () => {
try {
const url = await platform.getDefaultServerUrl?.()
if (!url) return null
return normalizeServerUrl(url) ?? null
} catch (err) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
return null
}
},
{ initialValue: null },
)
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
const fetcher = platform.fetch ?? globalThis.fetch
const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) return false
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
if (!host) return false
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
return host.includes(".") || host.includes(":")
}
const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
setStatus(undefined)
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
const result = await checkServerHealth(normalized, fetcher)
setStatus(result.healthy)
}
const resetAdd = () => { const resetAdd = () => {
setStore("addServer", { setStore("addServer", {
@@ -288,7 +263,7 @@ export function DialogSelectServer() {
} }
const scrollListToBottom = () => { const scrollListToBottom = () => {
const scroll = listRoot?.querySelector<HTMLDivElement>('[data-slot="list-scroll"]') const scroll = document.querySelector<HTMLDivElement>('[data-component="list"] [data-slot="list-scroll"]')
if (!scroll) return if (!scroll) return
requestAnimationFrame(() => { requestAnimationFrame(() => {
scroll.scrollTop = scroll.scrollHeight scroll.scrollTop = scroll.scrollHeight
@@ -388,134 +363,158 @@ export function DialogSelectServer() {
return ( return (
<Dialog title={language.t("dialog.server.title")}> <Dialog title={language.t("dialog.server.title")}>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div ref={(el) => (listRoot = el)}> <List
<List search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }} noInitialSelection
noInitialSelection emptyMessage={language.t("dialog.server.empty")}
emptyMessage={language.t("dialog.server.empty")} items={sortedItems}
items={sortedItems} key={(x) => x}
key={(x) => x} onSelect={(x) => {
onSelect={(x) => { if (x) select(x)
if (x) select(x) }}
}} onFilter={(value) => {
onFilter={(value) => { if (value && store.addServer.showForm && !store.addServer.adding) {
if (value && store.addServer.showForm && !store.addServer.adding) { resetAdd()
resetAdd()
}
}}
divider={true}
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
add={
store.addServer.showForm
? {
render: () => (
<AddRow
value={store.addServer.url}
placeholder={language.t("dialog.server.add.placeholder")}
adding={store.addServer.adding}
error={store.addServer.error}
status={store.addServer.status}
onChange={handleAddChange}
onKeyDown={handleAddKey}
onBlur={blurAdd}
/>
),
}
: undefined
} }
> }}
{(i) => { divider={true}
return ( class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
<div class="flex items-center gap-3 min-w-0 flex-1 group/item"> add={
<Show store.addServer.showForm
when={store.editServer.id !== i} ? {
fallback={ render: () => (
<EditRow <AddRow
value={store.editServer.value} value={store.addServer.url}
placeholder={language.t("dialog.server.add.placeholder")} placeholder={language.t("dialog.server.add.placeholder")}
busy={store.editServer.busy} adding={store.addServer.adding}
error={store.editServer.error} error={store.addServer.error}
status={store.editServer.status} status={store.addServer.status}
onChange={handleEditChange} onChange={handleAddChange}
onKeyDown={(event) => handleEditKey(event, i)} onKeyDown={handleAddKey}
onBlur={() => handleEdit(i, store.editServer.value)} onBlur={blurAdd}
/>
}
>
<ServerRow
url={i}
status={store.status[i]}
dimmed={store.status[i]?.healthy === false}
class="flex items-center gap-3 px-4 min-w-0 flex-1"
badge={
<Show when={defaultUrl() === i}>
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
}
/> />
</Show> ),
<Show when={store.editServer.id !== i}> }
<div class="flex items-center justify-center gap-5 pl-4"> : undefined
<Show when={current() === i}> }
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p> >
{(i) => {
return (
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
<Show
when={store.editServer.id !== i}
fallback={
<EditRow
value={store.editServer.value}
placeholder={language.t("dialog.server.add.placeholder")}
busy={store.editServer.busy}
error={store.editServer.error}
status={store.editServer.status}
onChange={handleEditChange}
onKeyDown={(event) => handleEditKey(event, i)}
onBlur={() => handleEdit(i, store.editServer.value)}
/>
}
>
<ServerRow
url={i}
status={store.status[i]}
dimmed={store.status[i]?.healthy === false}
class="flex items-center gap-3 px-4 min-w-0 flex-1"
badge={
<Show when={defaultUrl() === i}>
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show> </Show>
}
/>
</Show>
<Show when={store.editServer.id !== i}>
<div class="flex items-center justify-center gap-5 pl-4">
<Show when={current() === i}>
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
</Show>
<DropdownMenu> <DropdownMenu>
<DropdownMenu.Trigger <DropdownMenu.Trigger
as={IconButton} as={IconButton}
icon="dot-grid" icon="dot-grid"
variant="ghost" variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active" class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()} onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()} onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/> />
<DropdownMenu.Portal> <DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1"> <DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
setStore("editServer", {
id: i,
value: i,
error: "",
status: store.status[i]?.healthy,
})
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultUrl() !== i}>
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => { onSelect={async () => {
setStore("editServer", { try {
id: i, await platform.setDefaultServerUrl?.(i)
value: i, defaultUrlActions.mutate(i)
error: "", } catch (err) {
status: store.status[i]?.healthy, showToast({
}) variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
}} }}
> >
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
<Show when={canDefault() && defaultUrl() !== i}> </Show>
<DropdownMenu.Item onSelect={() => setDefault(i)}> <Show when={canDefault() && defaultUrl() === i}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultUrl() === i}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item <DropdownMenu.Item
onSelect={() => handleRemove(i)} onSelect={async () => {
class="text-text-on-critical-base hover:bg-surface-critical-weak" try {
await platform.setDefaultServerUrl?.(null)
defaultUrlActions.mutate(null)
} catch (err) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
}}
> >
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </Show>
</DropdownMenu.Portal> <DropdownMenu.Separator />
</DropdownMenu> <DropdownMenu.Item
</div> onSelect={() => handleRemove(i)}
</Show> class="text-text-on-critical-base hover:bg-surface-critical-weak"
</div> >
) <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
}} </DropdownMenu.Item>
</List> </DropdownMenu.Content>
</div> </DropdownMenu.Portal>
</DropdownMenu>
</div>
</Show>
</div>
)
}}
</List>
<div class="px-5 pb-5"> <div class="px-5 pb-5">
<Button <Button
@@ -67,6 +67,15 @@ export const DialogSettings: Component = () => {
<Tabs.Content value="models" class="no-scrollbar"> <Tabs.Content value="models" class="no-scrollbar">
<SettingsModels /> <SettingsModels />
</Tabs.Content> </Tabs.Content>
{/* <Tabs.Content value="agents" class="no-scrollbar"> */}
{/* <SettingsAgents /> */}
{/* </Tabs.Content> */}
{/* <Tabs.Content value="commands" class="no-scrollbar"> */}
{/* <SettingsCommands /> */}
{/* </Tabs.Content> */}
{/* <Tabs.Content value="mcp" class="no-scrollbar"> */}
{/* <SettingsMcp /> */}
{/* </Tabs.Content> */}
</Tabs> </Tabs>
</Dialog> </Dialog>
) )
+202 -278
View File
@@ -15,14 +15,11 @@ import {
Switch, Switch,
untrack, untrack,
type ComponentProps, type ComponentProps,
type JSXElement,
type ParentProps, type ParentProps,
} from "solid-js" } from "solid-js"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import type { FileNode } from "@opencode-ai/sdk/v2" import type { FileNode } from "@opencode-ai/sdk/v2"
const MAX_DEPTH = 128
function pathToFileUrl(filepath: string): string { function pathToFileUrl(filepath: string): string {
return `file://${encodeFilePath(filepath)}` return `file://${encodeFilePath(filepath)}`
} }
@@ -62,189 +59,6 @@ export function dirsToExpand(input: {
return [...input.filter.dirs].filter((dir) => !input.expanded(dir)) return [...input.filter.dirs].filter((dir) => !input.expanded(dir))
} }
const kindLabel = (kind: Kind) => {
if (kind === "add") return "A"
if (kind === "del") return "D"
return "M"
}
const kindTextColor = (kind: Kind) => {
if (kind === "add") return "color: var(--icon-diff-add-base)"
if (kind === "del") return "color: var(--icon-diff-delete-base)"
return "color: var(--icon-warning-active)"
}
const kindDotColor = (kind: Kind) => {
if (kind === "add") return "background-color: var(--icon-diff-add-base)"
if (kind === "del") return "background-color: var(--icon-diff-delete-base)"
return "background-color: var(--icon-warning-active)"
}
const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
const kind = kinds?.get(node.path)
if (!kind) return
if (!marks?.has(node.path)) return
return kind
}
const buildDragImage = (target: HTMLElement) => {
const icon = target.querySelector('[data-component="file-icon"]') ?? target.querySelector("svg")
const text = target.querySelector("span")
if (!icon || !text) return
const image = document.createElement("div")
image.className =
"flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong"
image.style.position = "absolute"
image.style.top = "-1000px"
image.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML
return image
}
const withFileDragImage = (event: DragEvent) => {
const image = buildDragImage(event.currentTarget as HTMLElement)
if (!image) return
document.body.appendChild(image)
event.dataTransfer?.setDragImage(image, 0, 12)
setTimeout(() => document.body.removeChild(image), 0)
}
const FileTreeNode = (
p: ParentProps &
ComponentProps<"div"> &
ComponentProps<"button"> & {
node: FileNode
level: number
active?: string
nodeClass?: string
draggable: boolean
kinds?: ReadonlyMap<string, Kind>
marks?: Set<string>
as?: "div" | "button"
},
) => {
const [local, rest] = splitProps(p, [
"node",
"level",
"active",
"nodeClass",
"draggable",
"kinds",
"marks",
"as",
"children",
"class",
"classList",
])
const kind = () => visibleKind(local.node, local.kinds, local.marks)
const active = () => !!kind() && !local.node.ignored
const color = () => {
const value = kind()
if (!value) return
return kindTextColor(value)
}
return (
<Dynamic
component={local.as ?? "div"}
classList={{
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
"bg-surface-base-active": local.node.path === local.active,
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
[local.nodeClass ?? ""]: !!local.nodeClass,
}}
style={`padding-left: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
draggable={local.draggable}
onDragStart={(event: DragEvent) => {
if (!local.draggable) return
event.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy"
withFileDragImage(event)
}}
{...rest}
>
{local.children}
<span
classList={{
"flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true,
"text-text-weaker": local.node.ignored,
"text-text-weak": !local.node.ignored && !active(),
}}
style={active() ? color() : undefined}
>
{local.node.name}
</span>
{(() => {
const value = kind()
if (!value) return null
if (local.node.type === "file") {
return (
<span class="shrink-0 w-4 text-center text-12-medium" style={kindTextColor(value)}>
{kindLabel(value)}
</span>
)
}
return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={kindDotColor(value)} />
})()}
</Dynamic>
)
}
const FileTreeNodeTooltip = (props: { enabled: boolean; node: FileNode; kind?: Kind; children: JSXElement }) => {
if (!props.enabled) return props.children
const parts = props.node.path.split("/")
const leaf = parts[parts.length - 1] ?? props.node.path
const head = parts.slice(0, -1).join("/")
const prefix = head ? `${head}/` : ""
const label =
props.kind === "add"
? "Additions"
: props.kind === "del"
? "Deletions"
: props.kind === "mix"
? "Modifications"
: undefined
return (
<Tooltip
openDelay={2000}
placement="bottom-start"
class="w-full"
contentStyle={{ "max-width": "480px", width: "fit-content" }}
value={
<div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
<span
class="min-w-0 truncate text-text-invert-base"
style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
>
{prefix}
</span>
<span class="shrink-0 text-text-invert-strong">{leaf}</span>
<Show when={label}>
{(text) => (
<>
<span class="mx-1 font-bold text-text-invert-strong"></span>
<span class="shrink-0 text-text-invert-strong">{text()}</span>
</>
)}
</Show>
<Show when={props.node.type === "directory" && props.node.ignored}>
<>
<span class="mx-1 font-bold text-text-invert-strong"></span>
<span class="shrink-0 text-text-invert-strong">Ignored</span>
</>
</Show>
</div>
}
>
{props.children}
</Tooltip>
)
}
export default function FileTree(props: { export default function FileTree(props: {
path: string path: string
class?: string class?: string
@@ -262,20 +76,12 @@ export default function FileTree(props: {
_marks?: Set<string> _marks?: Set<string>
_deeps?: Map<string, number> _deeps?: Map<string, number>
_kinds?: ReadonlyMap<string, Kind> _kinds?: ReadonlyMap<string, Kind>
_chain?: readonly string[]
}) { }) {
const file = useFile() const file = useFile()
const level = props.level ?? 0 const level = props.level ?? 0
const draggable = () => props.draggable ?? true const draggable = () => props.draggable ?? true
const tooltip = () => props.tooltip ?? 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(() => { const filter = createMemo(() => {
if (props._filter) return props._filter if (props._filter) return props._filter
@@ -317,45 +123,23 @@ export default function FileTree(props: {
const out = new Map<string, number>() const out = new Map<string, number>()
const root = props.path const visit = (dir: string, lvl: number): number => {
if (!(file.tree.state(root)?.expanded ?? false)) return out const expanded = file.tree.state(dir)?.expanded ?? false
if (!expanded) return -1
const seen = new Set<string>() const nodes = file.tree.children(dir)
const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = [] 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) => { out.set(dir, max)
const id = key(dir) return max
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)
} }
visit(props.path, level - 1)
return out return out
}) })
@@ -446,13 +230,178 @@ export default function FileTree(props: {
return out return out
}) })
const Node = (
p: ParentProps &
ComponentProps<"div"> &
ComponentProps<"button"> & {
node: FileNode
as?: "div" | "button"
},
) => {
const [local, rest] = splitProps(p, ["node", "as", "children", "class", "classList"])
return (
<Dynamic
component={local.as ?? "div"}
classList={{
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
"bg-surface-base-active": local.node.path === props.active,
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
[props.nodeClass ?? ""]: !!props.nodeClass,
}}
style={`padding-left: ${Math.max(0, 8 + level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
draggable={draggable()}
onDragStart={(e: DragEvent) => {
if (!draggable()) return
e.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
e.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"
const dragImage = document.createElement("div")
dragImage.className =
"flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong"
dragImage.style.position = "absolute"
dragImage.style.top = "-1000px"
const icon =
(e.currentTarget as HTMLElement).querySelector('[data-component="file-icon"]') ??
(e.currentTarget as HTMLElement).querySelector("svg")
const text = (e.currentTarget as HTMLElement).querySelector("span")
if (icon && text) {
dragImage.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML
}
document.body.appendChild(dragImage)
e.dataTransfer?.setDragImage(dragImage, 0, 12)
setTimeout(() => document.body.removeChild(dragImage), 0)
}}
{...rest}
>
{local.children}
{(() => {
const kind = kinds()?.get(local.node.path)
const marked = marks()?.has(local.node.path) ?? false
const active = !!kind && marked && !local.node.ignored
const color =
kind === "add"
? "color: var(--icon-diff-add-base)"
: kind === "del"
? "color: var(--icon-diff-delete-base)"
: kind === "mix"
? "color: var(--icon-warning-active)"
: undefined
return (
<span
classList={{
"flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true,
"text-text-weaker": local.node.ignored,
"text-text-weak": !local.node.ignored && !active,
}}
style={active ? color : undefined}
>
{local.node.name}
</span>
)
})()}
{(() => {
const kind = kinds()?.get(local.node.path)
if (!kind) return null
if (!marks()?.has(local.node.path)) return null
if (local.node.type === "file") {
const text = kind === "add" ? "A" : kind === "del" ? "D" : "M"
const color =
kind === "add"
? "color: var(--icon-diff-add-base)"
: kind === "del"
? "color: var(--icon-diff-delete-base)"
: "color: var(--icon-warning-active)"
return (
<span class="shrink-0 w-4 text-center text-12-medium" style={color}>
{text}
</span>
)
}
if (local.node.type === "directory") {
const color =
kind === "add"
? "background-color: var(--icon-diff-add-base)"
: kind === "del"
? "background-color: var(--icon-diff-delete-base)"
: "background-color: var(--icon-warning-active)"
return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={color} />
}
return null
})()}
</Dynamic>
)
}
return ( return (
<div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}> <div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
<For each={nodes()}> <For each={nodes()}>
{(node) => { {(node) => {
const expanded = () => file.tree.state(node.path)?.expanded ?? false const expanded = () => file.tree.state(node.path)?.expanded ?? false
const deep = () => deeps().get(node.path) ?? -1 const deep = () => deeps().get(node.path) ?? -1
const kind = () => visibleKind(node, kinds(), marks()) const Wrapper = (p: ParentProps) => {
if (!tooltip()) return p.children
const parts = node.path.split("/")
const leaf = parts[parts.length - 1] ?? node.path
const head = parts.slice(0, -1).join("/")
const prefix = head ? `${head}/` : ""
const kind = () => kinds()?.get(node.path)
const label = () => {
const k = kind()
if (!k) return
if (k === "add") return "Additions"
if (k === "del") return "Deletions"
return "Modifications"
}
const ignored = () => node.type === "directory" && node.ignored
return (
<Tooltip
openDelay={2000}
placement="bottom-start"
class="w-full"
contentStyle={{ "max-width": "480px", width: "fit-content" }}
value={
<div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
<span
class="min-w-0 truncate text-text-invert-base"
style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
>
{prefix}
</span>
<span class="shrink-0 text-text-invert-strong">{leaf}</span>
<Show when={label()}>
{(t: () => string) => (
<>
<span class="mx-1 font-bold text-text-invert-strong"></span>
<span class="shrink-0 text-text-invert-strong">{t()}</span>
</>
)}
</Show>
<Show when={ignored()}>
<>
<span class="mx-1 font-bold text-text-invert-strong"></span>
<span class="shrink-0 text-text-invert-strong">Ignored</span>
</>
</Show>
</div>
}
>
{p.children}
</Tooltip>
)
}
return ( return (
<Switch> <Switch>
@@ -466,21 +415,13 @@ export default function FileTree(props: {
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))} onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
> >
<Collapsible.Trigger> <Collapsible.Trigger>
<FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}> <Wrapper>
<FileTreeNode <Node node={node}>
node={node}
level={level}
active={props.active}
nodeClass={props.nodeClass}
draggable={draggable()}
kinds={kinds()}
marks={marks()}
>
<div class="size-4 flex items-center justify-center text-icon-weak"> <div class="size-4 flex items-center justify-center text-icon-weak">
<Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" /> <Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
</div> </div>
</FileTreeNode> </Node>
</FileTreeNodeTooltip> </Wrapper>
</Collapsible.Trigger> </Collapsible.Trigger>
<Collapsible.Content class="relative pt-0.5"> <Collapsible.Content class="relative pt-0.5">
<div <div
@@ -491,48 +432,31 @@ export default function FileTree(props: {
}} }}
style={`left: ${Math.max(0, 8 + level * 12 - 4) + 8}px`} style={`left: ${Math.max(0, 8 + level * 12 - 4) + 8}px`}
/> />
<Show <FileTree
when={level < MAX_DEPTH && !chain.includes(key(node.path))} path={node.path}
fallback={<div class="px-2 py-1 text-12-regular text-text-weak">...</div>} level={level + 1}
> allowed={props.allowed}
<FileTree modified={props.modified}
path={node.path} kinds={props.kinds}
level={level + 1} active={props.active}
allowed={props.allowed} draggable={props.draggable}
modified={props.modified} tooltip={props.tooltip}
kinds={props.kinds} onFileClick={props.onFileClick}
active={props.active} _filter={filter()}
draggable={props.draggable} _marks={marks()}
tooltip={props.tooltip} _deeps={deeps()}
onFileClick={props.onFileClick} _kinds={kinds()}
_filter={filter()} />
_marks={marks()}
_deeps={deeps()}
_kinds={kinds()}
_chain={chain}
/>
</Show>
</Collapsible.Content> </Collapsible.Content>
</Collapsible> </Collapsible>
</Match> </Match>
<Match when={node.type === "file"}> <Match when={node.type === "file"}>
<FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}> <Wrapper>
<FileTreeNode <Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}>
node={node}
level={level}
active={props.active}
nodeClass={props.nodeClass}
draggable={draggable()}
kinds={kinds()}
marks={marks()}
as="button"
type="button"
onClick={() => props.onFileClick?.(node)}
>
<div class="w-4 shrink-0" /> <div class="w-4 shrink-0" />
<FileIcon node={node} class="text-icon-weak size-4" /> <FileIcon node={node} class="text-icon-weak size-4" />
</FileTreeNode> </Node>
</FileTreeNodeTooltip> </Wrapper>
</Match> </Match>
</Switch> </Switch>
) )
+4 -13
View File
@@ -1,26 +1,17 @@
import { ComponentProps, splitProps } from "solid-js" import { ComponentProps, splitProps } from "solid-js"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
export interface LinkProps extends Omit<ComponentProps<"a">, "href"> { export interface LinkProps extends ComponentProps<"button"> {
href: string href: string
} }
export function Link(props: LinkProps) { export function Link(props: LinkProps) {
const platform = usePlatform() const platform = usePlatform()
const [local, rest] = splitProps(props, ["href", "children", "class"]) const [local, rest] = splitProps(props, ["href", "children"])
return ( return (
<a <button class="text-text-strong underline" onClick={() => platform.openLink(local.href)} {...rest}>
href={local.href}
class={`text-text-strong underline ${local.class ?? ""}`}
onClick={(event) => {
if (!local.href) return
event.preventDefault()
platform.openLink(local.href)
}}
{...rest}
>
{local.children} {local.children}
</a> </button>
) )
} }
+81 -87
View File
@@ -38,12 +38,7 @@ import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments" import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
import { import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history"
canNavigateHistoryAtCursor,
navigatePromptHistory,
prependHistoryEntry,
promptLength,
} from "./prompt-input/history"
import { createPromptSubmit } from "./prompt-input/submit" import { createPromptSubmit } from "./prompt-input/submit"
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover" import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
import { PromptContextItems } from "./prompt-input/context-items" import { PromptContextItems } from "./prompt-input/context-items"
@@ -163,13 +158,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path)) const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
if (wantsReview) { if (wantsReview) {
if (!view().reviewPanel.opened()) view().reviewPanel.open() if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.open()
layout.fileTree.setTab("changes") layout.fileTree.setTab("changes")
tabs().setActive("review")
requestAnimationFrame(() => comments.setFocus(focus)) requestAnimationFrame(() => comments.setFocus(focus))
return return
} }
if (!view().reviewPanel.opened()) view().reviewPanel.open() if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.open()
layout.fileTree.setTab("all") layout.fileTree.setTab("all")
const tab = files.tab(item.path) const tab = files.tab(item.path)
tabs().open(tab) tabs().open(tab)
@@ -281,47 +277,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isFocused = createFocusSignal(() => editorRef) const isFocused = createFocusSignal(() => editorRef)
const closePopover = () => setStore("popover", null)
const resetHistoryNavigation = (force = false) => {
if (!force && (store.historyIndex < 0 || store.applyingHistory)) return
setStore("historyIndex", -1)
setStore("savedPrompt", null)
}
const clearEditor = () => {
editorRef.innerHTML = ""
}
const setEditorText = (text: string) => {
clearEditor()
editorRef.textContent = text
}
const focusEditorEnd = () => {
requestAnimationFrame(() => {
editorRef.focus()
const range = document.createRange()
const selection = window.getSelection()
range.selectNodeContents(editorRef)
range.collapse(false)
selection?.removeAllRanges()
selection?.addRange(range)
})
}
const currentCursor = () => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) return null
return getCursorPosition(editorRef)
}
const renderEditorWithCursor = (parts: Prompt) => {
const cursor = currentCursor()
renderEditor(parts)
if (cursor !== null) setCursorPosition(editorRef, cursor)
}
createEffect(() => { createEffect(() => {
params.id params.id
if (params.id) return if (params.id) return
@@ -335,7 +290,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
createEffect(() => { createEffect(() => {
if (!isFocused()) closePopover() if (!isFocused()) setStore("popover", null)
}) })
// Safety: reset composing state on focus change to prevent stuck state // Safety: reset composing state on focus change to prevent stuck state
@@ -349,7 +304,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
.filter((agent) => !agent.hidden && agent.mode !== "primary") .filter((agent) => !agent.hidden && agent.mode !== "primary")
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })), .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
) )
const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name))
const handleAtSelect = (option: AtOption | undefined) => { const handleAtSelect = (option: AtOption | undefined) => {
if (!option) return if (!option) return
@@ -427,17 +381,26 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const handleSlashSelect = (cmd: SlashCommand | undefined) => { const handleSlashSelect = (cmd: SlashCommand | undefined) => {
if (!cmd) return if (!cmd) return
closePopover() setStore("popover", null)
if (cmd.type === "custom") { if (cmd.type === "custom") {
const text = `/${cmd.trigger} ` const text = `/${cmd.trigger} `
setEditorText(text) editorRef.innerHTML = ""
editorRef.textContent = text
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
focusEditorEnd() requestAnimationFrame(() => {
editorRef.focus()
const range = document.createRange()
const sel = window.getSelection()
range.selectNodeContents(editorRef)
range.collapse(false)
sel?.removeAllRanges()
sel?.addRange(range)
})
return return
} }
clearEditor() editorRef.innerHTML = ""
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
command.trigger(cmd.id, "slash") command.trigger(cmd.id, "slash")
} }
@@ -478,7 +441,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const prev = node.previousSibling const prev = node.previousSibling
const next = node.nextSibling const next = node.nextSibling
const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR" 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 if (node.nodeType !== Node.ELEMENT_NODE) return false
const el = node as HTMLElement const el = node as HTMLElement
@@ -488,7 +454,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}) })
const renderEditor = (parts: Prompt) => { const renderEditor = (parts: Prompt) => {
clearEditor() editorRef.innerHTML = ""
for (const part of parts) { for (const part of parts) {
if (part.type === "text") { if (part.type === "text") {
editorRef.appendChild(createTextFragment(part.content)) editorRef.appendChild(createTextFragment(part.content))
@@ -498,11 +464,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
editorRef.appendChild(createPill(part)) 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( createEffect(
@@ -553,14 +514,34 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
mirror.input = false mirror.input = false
if (isNormalizedEditor()) return if (isNormalizedEditor()) return
renderEditorWithCursor(inputParts) const selection = window.getSelection()
let cursorPosition: number | null = null
if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) {
cursorPosition = getCursorPosition(editorRef)
}
renderEditor(inputParts)
if (cursorPosition !== null) {
setCursorPosition(editorRef, cursorPosition)
}
return return
} }
const domParts = parseFromDOM() const domParts = parseFromDOM()
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
renderEditorWithCursor(inputParts) const selection = window.getSelection()
let cursorPosition: number | null = null
if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) {
cursorPosition = getCursorPosition(editorRef)
}
renderEditor(inputParts)
if (cursorPosition !== null) {
setCursorPosition(editorRef, cursorPosition)
}
}, },
), ),
) )
@@ -655,8 +636,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0 const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
if (shouldReset) { if (shouldReset) {
closePopover() setStore("popover", null)
resetHistoryNavigation() if (store.historyIndex >= 0 && !store.applyingHistory) {
setStore("historyIndex", -1)
setStore("savedPrompt", null)
}
if (prompt.dirty()) { if (prompt.dirty()) {
mirror.input = true mirror.input = true
prompt.set(DEFAULT_PROMPT, 0) prompt.set(DEFAULT_PROMPT, 0)
@@ -678,13 +662,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
slashOnInput(slashMatch[1]) slashOnInput(slashMatch[1])
setStore("popover", "slash") setStore("popover", "slash")
} else { } else {
closePopover() setStore("popover", null)
} }
} else { } else {
closePopover() setStore("popover", null)
} }
resetHistoryNavigation() if (store.historyIndex >= 0 && !store.applyingHistory) {
setStore("historyIndex", -1)
setStore("savedPrompt", null)
}
mirror.input = true mirror.input = true
prompt.set([...rawParts, ...images], cursorPosition) prompt.set([...rawParts, ...images], cursorPosition)
@@ -736,17 +723,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
if (last.nodeType !== Node.TEXT_NODE) { if (last.nodeType !== Node.TEXT_NODE) {
const isBreak = last.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR" range.setStartAfter(last)
const next = last.nextSibling
const emptyText = next?.nodeType === Node.TEXT_NODE && (next.textContent ?? "") === ""
if (isBreak && (!next || emptyText)) {
const placeholder = next && emptyText ? next : document.createTextNode("\u200B")
if (!next) last.parentNode?.insertBefore(placeholder, null)
placeholder.textContent = "\u200B"
range.setStart(placeholder, 0)
} else {
range.setStartAfter(last)
}
} }
} }
range.collapse(true) range.collapse(true)
@@ -755,7 +732,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
handleInput() handleInput()
closePopover() setStore("popover", null)
} }
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
@@ -805,7 +782,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
promptLength, promptLength,
addToHistory, addToHistory,
resetHistoryNavigation: () => { resetHistoryNavigation: () => {
resetHistoryNavigation(true) setStore("historyIndex", -1)
setStore("savedPrompt", null)
}, },
setMode: (mode) => setStore("mode", mode), setMode: (mode) => setStore("mode", mode),
setPopover: (popover) => setStore("popover", popover), setPopover: (popover) => setStore("popover", popover),
@@ -894,7 +872,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (ctrl && event.code === "KeyG") { if (ctrl && event.code === "KeyG") {
if (store.popover) { if (store.popover) {
closePopover() setStore("popover", null)
event.preventDefault() event.preventDefault()
return return
} }
@@ -911,13 +889,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!collapsed) return if (!collapsed) return
const cursorPosition = getCursorPosition(editorRef) const cursorPosition = getCursorPosition(editorRef)
const textLength = promptLength(prompt.current())
const textContent = prompt const textContent = prompt
.current() .current()
.map((part) => ("content" in part ? part.content : "")) .map((part) => ("content" in part ? part.content : ""))
.join("") .join("")
const direction = event.key === "ArrowUp" ? "up" : "down" const isEmpty = textContent.trim() === "" || textLength <= 1
if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition, store.historyIndex >= 0)) return const hasNewlines = textContent.includes("\n")
if (navigateHistory(direction)) { 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() event.preventDefault()
} }
return return
@@ -929,7 +923,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
if (event.key === "Escape") { if (event.key === "Escape") {
if (store.popover) { if (store.popover) {
closePopover() setStore("popover", null)
} else if (working()) { } else if (working()) {
abort() abort()
} }
@@ -1039,7 +1033,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
keybind={command.keybind("agent.cycle")} keybind={command.keybind("agent.cycle")}
> >
<Select <Select
options={agentNames()} options={local.agent.list().map((agent) => agent.name)}
current={local.agent.current()?.name ?? ""} current={local.agent.current()?.name ?? ""}
onSelect={local.agent.set} onSelect={local.agent.set}
class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-full" : "max-w-[120px]"}`} class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-full" : "max-w-[120px]"}`}
@@ -89,9 +89,6 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
} }
if (!plainText) return if (!plainText) return
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
if (inserted) return
input.addPart({ type: "text", content: plainText, start: 0, end: 0 }) input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
} }
@@ -20,68 +20,61 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
<Show when={props.items.length > 0}> <Show when={props.items.length > 0}>
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar"> <div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
<For each={props.items}> <For each={props.items}>
{(item) => { {(item) => (
const directory = getDirectory(item.path) <Tooltip
const filename = getFilename(item.path) value={
const label = getFilenameTruncated(item.path, 14) <span class="flex max-w-[300px]">
const selected = props.active(item) <span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
{getDirectory(item.path)}
return (
<Tooltip
value={
<span class="flex max-w-[300px]">
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
{directory}
</span>
<span class="shrink-0">{filename}</span>
</span> </span>
} <span class="shrink-0">{getFilename(item.path)}</span>
placement="top" </span>
openDelay={2000} }
placement="top"
openDelay={2000}
>
<div
classList={{
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !props.active(item),
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
props.active(item),
"bg-background-stronger": !props.active(item),
}}
onClick={() => props.openComment(item)}
> >
<div <div class="flex items-center gap-1.5">
classList={{ <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true, <div class="flex items-center text-11-regular min-w-0 font-medium">
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !selected, <span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover": <Show when={item.selection}>
selected, {(sel) => (
"bg-background-stronger": !selected, <span class="text-text-weak whitespace-nowrap shrink-0">
}} {sel().startLine === sel().endLine
onClick={() => props.openComment(item)} ? `:${sel().startLine}`
> : `:${sel().startLine}-${sel().endLine}`}
<div class="flex items-center gap-1.5"> </span>
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" /> )}
<div class="flex items-center text-11-regular min-w-0 font-medium"> </Show>
<span class="text-text-strong whitespace-nowrap">{label}</span>
<Show when={item.selection}>
{(sel) => (
<span class="text-text-weak whitespace-nowrap shrink-0">
{sel().startLine === sel().endLine
? `:${sel().startLine}`
: `:${sel().startLine}-${sel().endLine}`}
</span>
)}
</Show>
</div>
<IconButton
type="button"
icon="close-small"
variant="ghost"
class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
onClick={(e) => {
e.stopPropagation()
props.remove(item)
}}
aria-label={props.t("prompt.context.removeFile")}
/>
</div> </div>
<Show when={item.comment}> <IconButton
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>} type="button"
</Show> icon="close-small"
variant="ghost"
class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
onClick={(e) => {
e.stopPropagation()
props.remove(item)
}}
aria-label={props.t("prompt.context.removeFile")}
/>
</div> </div>
</Tooltip> <Show when={item.comment}>
) {(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
}} </Show>
</div>
</Tooltip>
)}
</For> </For>
</div> </div>
</Show> </Show>
@@ -6,17 +6,12 @@ type PromptDragOverlayProps = {
label: string label: string
} }
const kindToIcon = {
image: "photo",
"@mention": "link",
} as const
export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => { export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => {
return ( return (
<Show when={props.type !== null}> <Show when={props.type !== null}>
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none"> <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
<div class="flex flex-col items-center gap-2 text-text-weak"> <div class="flex flex-col items-center gap-2 text-text-weak">
<Icon name={props.type ? kindToIcon[props.type] : kindToIcon.image} class="size-8" /> <Icon name={props.type === "@mention" ? "link" : "photo"} class="size-8" />
<span class="text-14-regular">{props.label}</span> <span class="text-14-regular">{props.label}</span>
</div> </div>
</div> </div>
@@ -2,26 +2,17 @@ import { describe, expect, test } from "bun:test"
import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom" import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom"
describe("prompt-input 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 fragment = createTextFragment("foo\n\nbar")
const container = document.createElement("div") const container = document.createElement("div")
container.appendChild(fragment) container.appendChild(fragment)
expect(container.childNodes.length).toBe(4) 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] as HTMLElement).tagName).toBe("BR")
expect(container.childNodes[3]?.textContent).toBe("bar")
})
test("createTextFragment keeps trailing newline as terminal break", () => {
const fragment = createTextFragment("foo\n")
const container = document.createElement("div")
container.appendChild(fragment)
expect(container.childNodes.length).toBe(2)
expect(container.childNodes[0]?.textContent).toBe("foo") expect(container.childNodes[0]?.textContent).toBe("foo")
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR") 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", () => { test("length helpers treat breaks as one char and ignore zero-width chars", () => {
@@ -57,21 +48,4 @@ describe("prompt-input editor dom", () => {
container.remove() 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()
})
}) })
@@ -4,6 +4,8 @@ export function createTextFragment(content: string): DocumentFragment {
segments.forEach((segment, index) => { segments.forEach((segment, index) => {
if (segment) { if (segment) {
fragment.appendChild(document.createTextNode(segment)) fragment.appendChild(document.createTextNode(segment))
} else if (segments.length > 1) {
fragment.appendChild(document.createTextNode("\u200B"))
} }
if (index < segments.length - 1) { if (index < segments.length - 1) {
fragment.appendChild(document.createElement("br")) fragment.appendChild(document.createElement("br"))
@@ -1,12 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/context/prompt" import type { Prompt } from "@/context/prompt"
import { import { clonePromptParts, navigatePromptHistory, prependHistoryEntry, promptLength } from "./history"
canNavigateHistoryAtCursor,
clonePromptParts,
navigatePromptHistory,
prependHistoryEntry,
promptLength,
} from "./history"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] 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") if (original[1]?.type !== "file") throw new Error("expected file")
expect(original[1].selection?.startLine).toBe(1) 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)
})
}) })
@@ -4,15 +4,6 @@ const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
export const MAX_HISTORY = 100 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 { export function clonePromptParts(prompt: Prompt): Prompt {
return prompt.map((part) => { return prompt.map((part) => {
if (part.type === "text") return { ...part } if (part.type === "text") return { ...part }
@@ -9,13 +9,6 @@ type PromptImageAttachmentsProps = {
removeLabel: string removeLabel: string
} }
const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
const imageClass =
"size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
const removeClass =
"absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => { export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
return ( return (
<Show when={props.attachments.length > 0}> <Show when={props.attachments.length > 0}>
@@ -26,7 +19,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
<Show <Show
when={attachment.mime.startsWith("image/")} when={attachment.mime.startsWith("image/")}
fallback={ fallback={
<div class={fallbackClass}> <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
<Icon name="folder" class="size-6 text-text-weak" /> <Icon name="folder" class="size-6 text-text-weak" />
</div> </div>
} }
@@ -34,19 +27,19 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
<img <img
src={attachment.dataUrl} src={attachment.dataUrl}
alt={attachment.filename} alt={attachment.filename}
class={imageClass} class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
onClick={() => props.onOpen(attachment)} onClick={() => props.onOpen(attachment)}
/> />
</Show> </Show>
<button <button
type="button" type="button"
onClick={() => props.onRemove(attachment.id)} onClick={() => props.onRemove(attachment.id)}
class={removeClass} class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
aria-label={props.removeLabel} aria-label={props.removeLabel}
> >
<Icon name="close" class="size-3 text-text-weak" /> <Icon name="close" class="size-3 text-text-weak" />
</button> </button>
<div class={nameClass}> <div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
<span class="text-10-regular text-white truncate block">{attachment.filename}</span> <span class="text-10-regular text-white truncate block">{attachment.filename}</span>
</div> </div>
</div> </div>
@@ -52,44 +52,47 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>} fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
> >
<For each={props.atFlat.slice(0, 10)}> <For each={props.atFlat.slice(0, 10)}>
{(item) => { {(item) => (
const key = props.atKey(item) <button
classList={{
if (item.type === "agent") { "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
return ( "bg-surface-raised-base-hover": props.atActive === props.atKey(item),
<button }}
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5" onClick={() => props.onAtSelect(item)}
classList={{ "bg-surface-raised-base-hover": props.atActive === key }} onMouseEnter={() => props.setAtActive(props.atKey(item))}
onClick={() => props.onAtSelect(item)} >
onMouseEnter={() => props.setAtActive(key)} <Show
> when={item.type === "agent"}
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" /> fallback={
<span class="text-14-regular text-text-strong whitespace-nowrap">@{item.name}</span> <>
</button> <FileIcon
) node={{ path: item.type === "file" ? item.path : "", type: "file" }}
} class="shrink-0 size-4"
/>
const isDirectory = item.path.endsWith("/") <div class="flex items-center text-14-regular min-w-0">
const directory = isDirectory ? item.path : getDirectory(item.path) <span class="text-text-weak whitespace-nowrap truncate min-w-0">
const filename = isDirectory ? "" : getFilename(item.path) {item.type === "file"
? item.path.endsWith("/")
return ( ? item.path
<button : getDirectory(item.path)
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5" : ""}
classList={{ "bg-surface-raised-base-hover": props.atActive === key }} </span>
onClick={() => props.onAtSelect(item)} <Show when={item.type === "file" && !item.path.endsWith("/")}>
onMouseEnter={() => props.setAtActive(key)} <span class="text-text-strong whitespace-nowrap">
{item.type === "file" ? getFilename(item.path) : ""}
</span>
</Show>
</div>
</>
}
> >
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" /> <Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
<div class="flex items-center text-14-regular min-w-0"> <span class="text-14-regular text-text-strong whitespace-nowrap">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{directory}</span> @{item.type === "agent" ? item.name : ""}
<Show when={!isDirectory}> </span>
<span class="text-text-strong whitespace-nowrap">{filename}</span> </Show>
</Show> </button>
</div> )}
</button>
)
}}
</For> </For>
</Show> </Show>
</Match> </Match>
@@ -385,7 +385,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const send = async () => { const send = async () => {
const ok = await waitForWorktree() const ok = await waitForWorktree()
if (!ok) return if (!ok) return
await client.session.promptAsync({ await client.session.prompt({
sessionID: session.id, sessionID: session.id,
agent, agent,
model, model,
+37 -29
View File
@@ -38,45 +38,43 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
showToast({ title: language.t("common.requestFailed"), description: message }) showToast({ title: language.t("common.requestFailed"), description: message })
} }
const reply = async (answers: QuestionAnswer[]) => { const reply = (answers: QuestionAnswer[]) => {
if (store.sending) return if (store.sending) return
setStore("sending", true) setStore("sending", true)
try { sdk.client.question
await sdk.client.question.reply({ requestID: props.request.id, answers }) .reply({ requestID: props.request.id, answers })
} catch (err) { .catch(fail)
fail(err) .finally(() => setStore("sending", false))
} finally {
setStore("sending", false)
}
} }
const reject = async () => { const reject = () => {
if (store.sending) return if (store.sending) return
setStore("sending", true) setStore("sending", true)
try { sdk.client.question
await sdk.client.question.reject({ requestID: props.request.id }) .reject({ requestID: props.request.id })
} catch (err) { .catch(fail)
fail(err) .finally(() => setStore("sending", false))
} finally {
setStore("sending", false)
}
} }
const submit = () => { const submit = () => {
void reply(questions().map((_, i) => store.answers[i] ?? [])) reply(questions().map((_, i) => store.answers[i] ?? []))
} }
const pick = (answer: string, custom: boolean = false) => { const pick = (answer: string, custom: boolean = false) => {
setStore("answers", store.tab, [answer]) const answers = [...store.answers]
answers[store.tab] = [answer]
setStore("answers", answers)
if (custom) { if (custom) {
setStore("custom", store.tab, answer) const inputs = [...store.custom]
inputs[store.tab] = answer
setStore("custom", inputs)
} }
if (single()) { if (single()) {
void reply([[answer]]) reply([[answer]])
return return
} }
@@ -84,10 +82,15 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
} }
const toggle = (answer: string) => { const toggle = (answer: string) => {
setStore("answers", store.tab, (current = []) => { const existing = store.answers[store.tab] ?? []
if (current.includes(answer)) return current.filter((item) => item !== answer) const next = [...existing]
return [...current, answer] const index = next.indexOf(answer)
}) if (index === -1) next.push(answer)
if (index !== -1) next.splice(index, 1)
const answers = [...store.answers]
answers[store.tab] = next
setStore("answers", answers)
} }
const selectTab = (index: number) => { const selectTab = (index: number) => {
@@ -123,10 +126,13 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
} }
if (multi()) { if (multi()) {
setStore("answers", store.tab, (current = []) => { const existing = store.answers[store.tab] ?? []
if (current.includes(value)) return current const next = [...existing]
return [...current, value] if (!next.includes(value)) next.push(value)
})
const answers = [...store.answers]
answers[store.tab] = next
setStore("answers", answers)
setStore("editing", false) setStore("editing", false)
return return
} }
@@ -219,7 +225,9 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
value={input()} value={input()}
disabled={store.sending} disabled={store.sending}
onInput={(e) => { onInput={(e) => {
setStore("custom", store.tab, e.currentTarget.value) const inputs = [...store.custom]
inputs[store.tab] = e.currentTarget.value
setStore("custom", inputs)
}} }}
/> />
<Button type="submit" variant="primary" size="small" disabled={store.sending}> <Button type="submit" variant="primary" size="small" disabled={store.sending}>
@@ -1,5 +1,5 @@
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { JSXElement, ParentProps, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" import { JSXElement, ParentProps, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
import { serverDisplayName } from "@/context/server" import { serverDisplayName } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health" import type { ServerHealth } from "@/utils/server-health"
@@ -17,7 +17,6 @@ export function ServerRow(props: ServerRowProps) {
const [truncated, setTruncated] = createSignal(false) const [truncated, setTruncated] = createSignal(false)
let nameRef: HTMLSpanElement | undefined let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined let versionRef: HTMLSpanElement | undefined
const name = createMemo(() => serverDisplayName(props.url))
const check = () => { const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
@@ -26,24 +25,25 @@ export function ServerRow(props: ServerRowProps) {
} }
createEffect(() => { createEffect(() => {
name()
props.url props.url
props.status?.version props.status?.version
queueMicrotask(check) if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(check)
return
}
check()
}) })
onMount(() => { onMount(() => {
check() check()
if (typeof ResizeObserver !== "function") return if (typeof window === "undefined") return
const observer = new ResizeObserver(check) window.addEventListener("resize", check)
if (nameRef) observer.observe(nameRef) onCleanup(() => window.removeEventListener("resize", check))
if (versionRef) observer.observe(versionRef)
onCleanup(() => observer.disconnect())
}) })
const tooltipValue = () => ( const tooltipValue = () => (
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<span>{name()}</span> <span>{serverDisplayName(props.url)}</span>
<Show when={props.status?.version}> <Show when={props.status?.version}>
<span class="text-text-invert-base">{props.status?.version}</span> <span class="text-text-invert-base">{props.status?.version}</span>
</Show> </Show>
@@ -62,7 +62,7 @@ export function ServerRow(props: ServerRowProps) {
}} }}
/> />
<span ref={nameRef} class={props.nameClass ?? "truncate"}> <span ref={nameRef} class={props.nameClass ?? "truncate"}>
{name()} {serverDisplayName(props.url)}
</span> </span>
<Show when={props.status?.version}> <Show when={props.status?.version}>
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}> <span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
@@ -13,17 +13,6 @@ interface SessionContextUsageProps {
variant?: "button" | "indicator" variant?: "button" | "indicator"
} }
function openSessionContext(args: {
view: ReturnType<ReturnType<typeof useLayout>["view"]>
layout: ReturnType<typeof useLayout>
tabs: ReturnType<ReturnType<typeof useLayout>["tabs"]>
}) {
if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open()
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
args.tabs.open("context")
args.tabs.setActive("context")
}
export function SessionContextUsage(props: SessionContextUsageProps) { export function SessionContextUsage(props: SessionContextUsageProps) {
const sync = useSync() const sync = useSync()
const params = useParams() const params = useParams()
@@ -52,11 +41,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const openContext = () => { const openContext = () => {
if (!params.id) return if (!params.id) return
openSessionContext({ if (!view().reviewPanel.opened()) view().reviewPanel.open()
view: view(), layout.fileTree.open()
layout, layout.fileTree.setTab("all")
tabs: tabs(), tabs().open("context")
}) tabs().setActive("context")
} }
const circle = () => ( const circle = () => (
@@ -1,61 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { estimateSessionContextBreakdown } from "./session-context-breakdown"
const user = (id: string) => {
return {
id,
role: "user",
time: { created: 1 },
} as unknown as Message
}
const assistant = (id: string) => {
return {
id,
role: "assistant",
time: { created: 1 },
} as unknown as Message
}
describe("estimateSessionContextBreakdown", () => {
test("estimates tokens and keeps remaining tokens as other", () => {
const messages = [user("u1"), assistant("a1")]
const parts = {
u1: [{ type: "text", text: "hello world" }] as unknown as Part[],
a1: [{ type: "text", text: "assistant response" }] as unknown as Part[],
}
const output = estimateSessionContextBreakdown({
messages,
parts,
input: 20,
systemPrompt: "system prompt",
})
const map = Object.fromEntries(output.map((segment) => [segment.key, segment.tokens]))
expect(map.system).toBe(4)
expect(map.user).toBe(3)
expect(map.assistant).toBe(5)
expect(map.other).toBe(8)
})
test("scales segments when estimates exceed input", () => {
const messages = [user("u1"), assistant("a1")]
const parts = {
u1: [{ type: "text", text: "x".repeat(400) }] as unknown as Part[],
a1: [{ type: "text", text: "y".repeat(400) }] as unknown as Part[],
}
const output = estimateSessionContextBreakdown({
messages,
parts,
input: 10,
systemPrompt: "z".repeat(200),
})
const total = output.reduce((sum, segment) => sum + segment.tokens, 0)
expect(total).toBeLessThanOrEqual(10)
expect(output.every((segment) => segment.width <= 100)).toBeTrue()
})
})
@@ -1,132 +0,0 @@
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other"
export type SessionContextBreakdownSegment = {
key: SessionContextBreakdownKey
tokens: number
width: number
percent: number
}
const estimateTokens = (chars: number) => Math.ceil(chars / 4)
const toPercent = (tokens: number, input: number) => (tokens / input) * 100
const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10
const charsFromUserPart = (part: Part) => {
if (part.type === "text") return part.text.length
if (part.type === "file") return part.source?.text.value.length ?? 0
if (part.type === "agent") return part.source?.value.length ?? 0
return 0
}
const charsFromAssistantPart = (part: Part) => {
if (part.type === "text") return { assistant: part.text.length, tool: 0 }
if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 }
if (part.type !== "tool") return { assistant: 0, tool: 0 }
const input = Object.keys(part.state.input).length * 16
if (part.state.status === "pending") return { assistant: 0, tool: input + part.state.raw.length }
if (part.state.status === "completed") return { assistant: 0, tool: input + part.state.output.length }
if (part.state.status === "error") return { assistant: 0, tool: input + part.state.error.length }
return { assistant: 0, tool: input }
}
const build = (
tokens: { system: number; user: number; assistant: number; tool: number; other: number },
input: number,
) => {
return [
{
key: "system",
tokens: tokens.system,
},
{
key: "user",
tokens: tokens.user,
},
{
key: "assistant",
tokens: tokens.assistant,
},
{
key: "tool",
tokens: tokens.tool,
},
{
key: "other",
tokens: tokens.other,
},
]
.filter((x) => x.tokens > 0)
.map((x) => ({
key: x.key,
tokens: x.tokens,
width: toPercent(x.tokens, input),
percent: toPercentLabel(x.tokens, input),
})) as SessionContextBreakdownSegment[]
}
export function estimateSessionContextBreakdown(args: {
messages: Message[]
parts: Record<string, Part[] | undefined>
input: number
systemPrompt?: string
}) {
if (!args.input) return []
const counts = args.messages.reduce(
(acc, msg) => {
const parts = args.parts[msg.id] ?? []
if (msg.role === "user") {
const user = parts.reduce((sum, part) => sum + charsFromUserPart(part), 0)
return { ...acc, user: acc.user + user }
}
if (msg.role !== "assistant") return acc
const assistant = parts.reduce(
(sum, part) => {
const next = charsFromAssistantPart(part)
return {
assistant: sum.assistant + next.assistant,
tool: sum.tool + next.tool,
}
},
{ assistant: 0, tool: 0 },
)
return {
...acc,
assistant: acc.assistant + assistant.assistant,
tool: acc.tool + assistant.tool,
}
},
{
system: args.systemPrompt?.length ?? 0,
user: 0,
assistant: 0,
tool: 0,
},
)
const tokens = {
system: estimateTokens(counts.system),
user: estimateTokens(counts.user),
assistant: estimateTokens(counts.assistant),
tool: estimateTokens(counts.tool),
}
const estimated = tokens.system + tokens.user + tokens.assistant + tokens.tool
if (estimated <= args.input) {
return build({ ...tokens, other: args.input - estimated }, args.input)
}
const scale = args.input / estimated
const scaled = {
system: Math.floor(tokens.system * scale),
user: Math.floor(tokens.user * scale),
assistant: Math.floor(tokens.assistant * scale),
tool: Math.floor(tokens.tool * scale),
}
const total = scaled.system + scaled.user + scaled.assistant + scaled.tool
return build({ ...scaled, other: Math.max(0, args.input - total) }, args.input)
}
@@ -1,20 +0,0 @@
import { DateTime } from "luxon"
export function createSessionContextFormatter(locale: string) {
return {
number(value: number | null | undefined) {
if (value === undefined) return "—"
if (value === null) return "—"
return value.toLocaleString(locale)
},
percent(value: number | null | undefined) {
if (value === undefined) return "—"
if (value === null) return "—"
return value.toLocaleString(locale) + "%"
},
time(value: number | undefined) {
if (!value) return "—"
return DateTime.fromMillis(value).setLocale(locale).toLocaleString(DateTime.DATETIME_MED)
},
}
}
@@ -91,11 +91,4 @@ describe("getSessionContextMetrics", () => {
expect(two.context?.message.id).toBe("a2") expect(two.context?.message.id).toBe("a2")
expect(two.totalCost).toBe(1) expect(two.totalCost).toBe(1)
}) })
test("returns empty metrics when inputs are undefined", () => {
const metrics = getSessionContextMetrics(undefined, undefined)
expect(metrics.totalCost).toBe(0)
expect(metrics.context).toBeUndefined()
})
}) })
@@ -47,7 +47,7 @@ const lastAssistantWithTokens = (messages: Message[]) => {
} }
} }
const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => { const build = (messages: Message[], providers: Provider[]): Metrics => {
const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0) const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
const message = lastAssistantWithTokens(messages) const message = lastAssistantWithTokens(messages)
if (!message) return { totalCost, context: undefined } if (!message) return { totalCost, context: undefined }
@@ -77,6 +77,6 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Metrics =>
} }
} }
export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = []) { export function getSessionContextMetrics(messages: Message[], providers: Provider[]) {
return build(messages, providers) return build(messages, providers)
} }
@@ -1,6 +1,7 @@
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
import type { JSX } from "solid-js" import type { JSX } from "solid-js"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { DateTime } from "luxon"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { checksum } from "@opencode-ai/util/encode" import { checksum } from "@opencode-ai/util/encode"
@@ -13,8 +14,6 @@ import { Markdown } from "@opencode-ai/ui/markdown"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client" import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { getSessionContextMetrics } from "./session-context-metrics" import { getSessionContextMetrics } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format"
interface SessionContextTabProps { interface SessionContextTabProps {
messages: () => Message[] messages: () => Message[]
@@ -23,74 +22,6 @@ interface SessionContextTabProps {
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]> info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
} }
const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
system: "var(--syntax-info)",
user: "var(--syntax-success)",
assistant: "var(--syntax-property)",
tool: "var(--syntax-warning)",
other: "var(--syntax-comment)",
}
function Stat(props: { label: string; value: JSX.Element }) {
return (
<div class="flex flex-col gap-1">
<div class="text-12-regular text-text-weak">{props.label}</div>
<div class="text-12-medium text-text-strong">{props.value}</div>
</div>
)
}
function RawMessageContent(props: { message: Message; getParts: (id: string) => Part[]; onRendered: () => void }) {
const file = createMemo(() => {
const parts = props.getParts(props.message.id)
const contents = JSON.stringify({ message: props.message, parts }, null, 2)
return {
name: `${props.message.role}-${props.message.id}.json`,
contents,
cacheKey: checksum(contents),
}
})
return (
<Code
file={file()}
overflow="wrap"
class="select-text"
onRendered={() => requestAnimationFrame(props.onRendered)}
/>
)
}
function RawMessage(props: {
message: Message
getParts: (id: string) => Part[]
onRendered: () => void
time: (value: number | undefined) => string
}) {
return (
<Accordion.Item value={props.message.id}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div class="flex items-center justify-between gap-2 w-full">
<div class="min-w-0 truncate">
{props.message.role} <span class="text-text-base"> {props.message.id}</span>
</div>
<div class="flex items-center gap-3">
<div class="shrink-0 text-12-regular text-text-weak">{props.time(props.message.time.created)}</div>
<Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" />
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content class="bg-background-base">
<div class="p-3">
<RawMessageContent message={props.message} getParts={props.getParts} onRendered={props.onRendered} />
</div>
</Accordion.Content>
</Accordion.Item>
)
}
export function SessionContextTab(props: SessionContextTabProps) { export function SessionContextTab(props: SessionContextTabProps) {
const params = useParams() const params = useParams()
const sync = useSync() const sync = useSync()
@@ -106,7 +37,6 @@ export function SessionContextTab(props: SessionContextTabProps) {
const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all)) const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
const ctx = createMemo(() => metrics().context) const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
const cost = createMemo(() => { const cost = createMemo(() => {
return usd().format(metrics().totalCost) return usd().format(metrics().totalCost)
@@ -132,6 +62,23 @@ export function SessionContextTab(props: SessionContextTabProps) {
return trimmed return trimmed
}) })
const number = (value: number | null | undefined) => {
if (value === undefined) return "—"
if (value === null) return "—"
return value.toLocaleString(language.locale())
}
const percent = (value: number | null | undefined) => {
if (value === undefined) return "—"
if (value === null) return "—"
return value.toLocaleString(language.locale()) + "%"
}
const time = (value: number | undefined) => {
if (!value) return "—"
return DateTime.fromMillis(value).setLocale(language.locale()).toLocaleString(DateTime.DATETIME_MED)
}
const providerLabel = createMemo(() => { const providerLabel = createMemo(() => {
const c = ctx() const c = ctx()
if (!c) return "—" if (!c) return "—"
@@ -149,51 +96,197 @@ export function SessionContextTab(props: SessionContextTabProps) {
() => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()], () => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
() => { () => {
const c = ctx() const c = ctx()
if (!c?.input) return [] if (!c) return []
return estimateSessionContextBreakdown({ const input = c.input
messages: props.messages(), if (!input) return []
parts: sync.data.part as Record<string, Part[] | undefined>,
input: c.input, const out = {
systemPrompt: systemPrompt(), system: systemPrompt()?.length ?? 0,
}) user: 0,
assistant: 0,
tool: 0,
}
for (const msg of props.messages()) {
const parts = (sync.data.part[msg.id] ?? []) as Part[]
if (msg.role === "user") {
for (const part of parts) {
if (part.type === "text") out.user += part.text.length
if (part.type === "file") out.user += part.source?.text.value.length ?? 0
if (part.type === "agent") out.user += part.source?.value.length ?? 0
}
continue
}
if (msg.role === "assistant") {
for (const part of parts) {
if (part.type === "text") out.assistant += part.text.length
if (part.type === "reasoning") out.assistant += part.text.length
if (part.type === "tool") {
out.tool += Object.keys(part.state.input).length * 16
if (part.state.status === "pending") out.tool += part.state.raw.length
if (part.state.status === "completed") out.tool += part.state.output.length
if (part.state.status === "error") out.tool += part.state.error.length
}
}
}
}
const estimateTokens = (chars: number) => Math.ceil(chars / 4)
const system = estimateTokens(out.system)
const user = estimateTokens(out.user)
const assistant = estimateTokens(out.assistant)
const tool = estimateTokens(out.tool)
const estimated = system + user + assistant + tool
const pct = (tokens: number) => (tokens / input) * 100
const pctLabel = (tokens: number) => (Math.round(pct(tokens) * 10) / 10).toString() + "%"
const build = (tokens: { system: number; user: number; assistant: number; tool: number; other: number }) => {
return [
{
key: "system",
label: language.t("context.breakdown.system"),
tokens: tokens.system,
width: pct(tokens.system),
percent: pctLabel(tokens.system),
color: "var(--syntax-info)",
},
{
key: "user",
label: language.t("context.breakdown.user"),
tokens: tokens.user,
width: pct(tokens.user),
percent: pctLabel(tokens.user),
color: "var(--syntax-success)",
},
{
key: "assistant",
label: language.t("context.breakdown.assistant"),
tokens: tokens.assistant,
width: pct(tokens.assistant),
percent: pctLabel(tokens.assistant),
color: "var(--syntax-property)",
},
{
key: "tool",
label: language.t("context.breakdown.tool"),
tokens: tokens.tool,
width: pct(tokens.tool),
percent: pctLabel(tokens.tool),
color: "var(--syntax-warning)",
},
{
key: "other",
label: language.t("context.breakdown.other"),
tokens: tokens.other,
width: pct(tokens.other),
percent: pctLabel(tokens.other),
color: "var(--syntax-comment)",
},
].filter((x) => x.tokens > 0)
}
if (estimated <= input) {
return build({ system, user, assistant, tool, other: input - estimated })
}
const scale = input / estimated
const scaled = {
system: Math.floor(system * scale),
user: Math.floor(user * scale),
assistant: Math.floor(assistant * scale),
tool: Math.floor(tool * scale),
}
const scaledTotal = scaled.system + scaled.user + scaled.assistant + scaled.tool
return build({ ...scaled, other: Math.max(0, input - scaledTotal) })
}, },
), ),
) )
const breakdownLabel = (key: SessionContextBreakdownKey) => { function Stat(statProps: { label: string; value: JSX.Element }) {
if (key === "system") return language.t("context.breakdown.system") return (
if (key === "user") return language.t("context.breakdown.user") <div class="flex flex-col gap-1">
if (key === "assistant") return language.t("context.breakdown.assistant") <div class="text-12-regular text-text-weak">{statProps.label}</div>
if (key === "tool") return language.t("context.breakdown.tool") <div class="text-12-medium text-text-strong">{statProps.value}</div>
return language.t("context.breakdown.other") </div>
)
} }
const stats = [ const stats = createMemo(() => {
{ label: "context.stats.session", value: () => props.info()?.title ?? params.id ?? "—" }, const c = ctx()
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) }, const count = counts()
{ label: "context.stats.provider", value: providerLabel }, return [
{ label: "context.stats.model", value: modelLabel }, { label: language.t("context.stats.session"), value: props.info()?.title ?? params.id ?? "—" },
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) }, { label: language.t("context.stats.messages"), value: count.all.toLocaleString(language.locale()) },
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) }, { label: language.t("context.stats.provider"), value: providerLabel() },
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) }, { label: language.t("context.stats.model"), value: modelLabel() },
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) }, { label: language.t("context.stats.limit"), value: number(c?.limit) },
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) }, { label: language.t("context.stats.totalTokens"), value: number(c?.total) },
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.reasoning) }, { label: language.t("context.stats.usage"), value: percent(c?.usage) },
{ { label: language.t("context.stats.inputTokens"), value: number(c?.input) },
label: "context.stats.cacheTokens", { label: language.t("context.stats.outputTokens"), value: number(c?.output) },
value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`, { label: language.t("context.stats.reasoningTokens"), value: number(c?.reasoning) },
}, {
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) }, label: language.t("context.stats.cacheTokens"),
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) }, value: `${number(c?.cacheRead)} / ${number(c?.cacheWrite)}`,
{ label: "context.stats.totalCost", value: cost }, },
{ label: "context.stats.sessionCreated", value: () => formatter().time(props.info()?.time.created) }, { label: language.t("context.stats.userMessages"), value: count.user.toLocaleString(language.locale()) },
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) }, {
] satisfies { label: string; value: () => JSX.Element }[] label: language.t("context.stats.assistantMessages"),
value: count.assistant.toLocaleString(language.locale()),
},
{ label: language.t("context.stats.totalCost"), value: cost() },
{ label: language.t("context.stats.sessionCreated"), value: time(props.info()?.time.created) },
{ label: language.t("context.stats.lastActivity"), value: time(c?.message.time.created) },
] satisfies { label: string; value: JSX.Element }[]
})
function RawMessageContent(msgProps: { message: Message }) {
const file = createMemo(() => {
const parts = (sync.data.part[msgProps.message.id] ?? []) as Part[]
const contents = JSON.stringify({ message: msgProps.message, parts }, null, 2)
return {
name: `${msgProps.message.role}-${msgProps.message.id}.json`,
contents,
cacheKey: checksum(contents),
}
})
return (
<Code file={file()} overflow="wrap" class="select-text" onRendered={() => requestAnimationFrame(restoreScroll)} />
)
}
function RawMessage(msgProps: { message: Message }) {
return (
<Accordion.Item value={msgProps.message.id}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div class="flex items-center justify-between gap-2 w-full">
<div class="min-w-0 truncate">
{msgProps.message.role} <span class="text-text-base"> {msgProps.message.id}</span>
</div>
<div class="flex items-center gap-3">
<div class="shrink-0 text-12-regular text-text-weak">{time(msgProps.message.time.created)}</div>
<Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" />
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content class="bg-background-base">
<div class="p-3">
<RawMessageContent message={msgProps.message} />
</div>
</Accordion.Content>
</Accordion.Item>
)
}
let scroll: HTMLDivElement | undefined let scroll: HTMLDivElement | undefined
let frame: number | undefined let frame: number | undefined
let pending: { x: number; y: number } | undefined let pending: { x: number; y: number } | undefined
const getParts = (id: string) => (sync.data.part[id] ?? []) as Part[]
const restoreScroll = () => { const restoreScroll = () => {
const el = scroll const el = scroll
@@ -250,9 +343,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
> >
<div class="px-6 pt-4 flex flex-col gap-10"> <div class="px-6 pt-4 flex flex-col gap-10">
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4"> <div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
<For each={stats}> <For each={stats()}>{(stat) => <Stat label={stat.label} value={stat.value} />}</For>
{(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}
</For>
</div> </div>
<Show when={breakdown().length > 0}> <Show when={breakdown().length > 0}>
@@ -265,7 +356,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
class="h-full" class="h-full"
style={{ style={{
width: `${segment.width}%`, width: `${segment.width}%`,
"background-color": BREAKDOWN_COLOR[segment.key], "background-color": segment.color,
}} }}
/> />
)} )}
@@ -275,9 +366,9 @@ export function SessionContextTab(props: SessionContextTabProps) {
<For each={breakdown()}> <For each={breakdown()}>
{(segment) => ( {(segment) => (
<div class="flex items-center gap-1 text-11-regular text-text-weak"> <div class="flex items-center gap-1 text-11-regular text-text-weak">
<div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} /> <div class="size-2 rounded-sm" style={{ "background-color": segment.color }} />
<div>{breakdownLabel(segment.key)}</div> <div>{segment.label}</div>
<div class="text-text-weaker">{segment.percent.toLocaleString(language.locale())}%</div> <div class="text-text-weaker">{segment.percent}</div>
</div> </div>
)} )}
</For> </For>
@@ -300,11 +391,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div> <div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
<Accordion multiple> <Accordion multiple>
<For each={props.messages()}> <For each={props.messages()}>{(message) => <RawMessage message={message} />}</For>
{(message) => (
<RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
)}
</For>
</Accordion> </Accordion>
</div> </div>
</div> </div>
@@ -1,4 +1,4 @@
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js" import { createEffect, createMemo, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web" import { Portal } from "solid-js/web"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
@@ -25,164 +25,6 @@ import { Keybind } from "@opencode-ai/ui/keybind"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { StatusPopover } from "../status-popover" import { StatusPopover } from "../status-popover"
const OPEN_APPS = [
"vscode",
"cursor",
"zed",
"textmate",
"antigravity",
"finder",
"terminal",
"iterm2",
"ghostty",
"xcode",
"android-studio",
"powershell",
"sublime-text",
] as const
type OpenApp = (typeof OPEN_APPS)[number]
type OS = "macos" | "windows" | "linux" | "unknown"
const MAC_APPS = [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
{ id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
{ id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" },
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
{ id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" },
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
] as const
const WINDOWS_APPS = [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
{ id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" },
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
] as const
const LINUX_APPS = [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
] as const
type OpenOption = (typeof MAC_APPS)[number] | (typeof WINDOWS_APPS)[number] | (typeof LINUX_APPS)[number]
type OpenIcon = OpenApp | "file-explorer"
const OPEN_ICON_BASE = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"])
const openIconSize = (id: OpenIcon) => (OPEN_ICON_BASE.has(id) ? "size-4" : "size-[19px]")
const detectOS = (platform: ReturnType<typeof usePlatform>): OS => {
if (platform.platform === "desktop" && platform.os) return platform.os
if (typeof navigator !== "object") return "unknown"
const value = navigator.platform || navigator.userAgent
if (/Mac/i.test(value)) return "macos"
if (/Win/i.test(value)) return "windows"
if (/Linux/i.test(value)) return "linux"
return "unknown"
}
const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
function useSessionShare(args: {
globalSDK: ReturnType<typeof useGlobalSDK>
currentSession: () =>
| {
id: string
share?: {
url?: string
}
}
| undefined
projectDirectory: () => string
platform: ReturnType<typeof usePlatform>
}) {
const [state, setState] = createStore({
share: false,
unshare: false,
copied: false,
timer: undefined as number | undefined,
})
const shareUrl = createMemo(() => args.currentSession()?.share?.url)
createEffect(() => {
const url = shareUrl()
if (url) return
if (state.timer) window.clearTimeout(state.timer)
setState({ copied: false, timer: undefined })
})
onCleanup(() => {
if (state.timer) window.clearTimeout(state.timer)
})
const shareSession = () => {
const session = args.currentSession()
if (!session || state.share) return
setState("share", true)
args.globalSDK.client.session
.share({ sessionID: session.id, directory: args.projectDirectory() })
.catch((error) => {
console.error("Failed to share session", error)
})
.finally(() => {
setState("share", false)
})
}
const unshareSession = () => {
const session = args.currentSession()
if (!session || state.unshare) return
setState("unshare", true)
args.globalSDK.client.session
.unshare({ sessionID: session.id, directory: args.projectDirectory() })
.catch((error) => {
console.error("Failed to unshare session", error)
})
.finally(() => {
setState("unshare", false)
})
}
const copyLink = (onError: (error: unknown) => void) => {
const url = shareUrl()
if (!url) return
navigator.clipboard
.writeText(url)
.then(() => {
if (state.timer) window.clearTimeout(state.timer)
setState("copied", true)
const timer = window.setTimeout(() => {
setState("copied", false)
setState("timer", undefined)
}, 3000)
setState("timer", timer)
})
.catch(onError)
}
const viewShare = () => {
const url = shareUrl()
if (!url) return
args.platform.openLink(url)
}
return { state, shareUrl, shareSession, unshareSession, copyLink, viewShare }
}
export function SessionHeader() { export function SessionHeader() {
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const layout = useLayout() const layout = useLayout()
@@ -211,7 +53,62 @@ export function SessionHeader() {
const showShare = createMemo(() => shareEnabled() && !!currentSession()) const showShare = createMemo(() => shareEnabled() && !!currentSession())
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey)) const view = createMemo(() => layout.view(sessionKey))
const os = createMemo(() => detectOS(platform))
const OPEN_APPS = [
"vscode",
"cursor",
"zed",
"textmate",
"antigravity",
"finder",
"terminal",
"iterm2",
"ghostty",
"xcode",
"android-studio",
"powershell",
"sublime-text",
] as const
type OpenApp = (typeof OPEN_APPS)[number]
const MAC_APPS = [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
{ id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
{ id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" },
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
{ id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" },
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
] as const
const WINDOWS_APPS = [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
{ id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" },
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
] as const
const LINUX_APPS = [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
] as const
const os = createMemo<"macos" | "windows" | "linux" | "unknown">(() => {
if (platform.platform === "desktop" && platform.os) return platform.os
if (typeof navigator !== "object") return "unknown"
const value = navigator.platform || navigator.userAgent
if (/Mac/i.test(value)) return "macos"
if (/Win/i.test(value)) return "windows"
if (/Linux/i.test(value)) return "linux"
return "unknown"
})
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ finder: true }) const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ finder: true })
@@ -257,6 +154,10 @@ export function SessionHeader() {
] as const ] as const
}) })
type OpenIcon = OpenApp | "file-explorer"
const base = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"])
const size = (id: OpenIcon) => (base.has(id) ? "size-4" : "size-[19px]")
const checksReady = createMemo(() => { const checksReady = createMemo(() => {
if (platform.platform !== "desktop") return true if (platform.platform !== "desktop") return true
if (!platform.checkAppExists) return true if (!platform.checkAppExists) return true
@@ -285,7 +186,13 @@ export function SessionHeader() {
const item = options().find((o) => o.id === app) const item = options().find((o) => o.id === app)
const openWith = item && "openWith" in item ? item.openWith : undefined const openWith = item && "openWith" in item ? item.openWith : undefined
Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => showRequestError(language, err)) Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
})
} }
const copyPath = () => { const copyPath = () => {
@@ -301,24 +208,93 @@ export function SessionHeader() {
description: directory, description: directory,
}) })
}) })
.catch((err: unknown) => showRequestError(language, err)) .catch((err: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
})
} }
const share = useSessionShare({ const [state, setState] = createStore({
globalSDK, share: false,
currentSession, unshare: false,
projectDirectory, copied: false,
platform, timer: undefined as number | undefined,
})
const shareUrl = createMemo(() => currentSession()?.share?.url)
createEffect(() => {
const url = shareUrl()
if (url) return
if (state.timer) window.clearTimeout(state.timer)
setState({ copied: false, timer: undefined })
}) })
const leftMount = createMemo( onCleanup(() => {
() => document.getElementById("opencode-titlebar-left") ?? document.getElementById("opencode-titlebar-center"), if (state.timer) window.clearTimeout(state.timer)
) })
function shareSession() {
const session = currentSession()
if (!session || state.share) return
setState("share", true)
globalSDK.client.session
.share({ sessionID: session.id, directory: projectDirectory() })
.catch((error) => {
console.error("Failed to share session", error)
})
.finally(() => {
setState("share", false)
})
}
function unshareSession() {
const session = currentSession()
if (!session || state.unshare) return
setState("unshare", true)
globalSDK.client.session
.unshare({ sessionID: session.id, directory: projectDirectory() })
.catch((error) => {
console.error("Failed to unshare session", error)
})
.finally(() => {
setState("unshare", false)
})
}
function copyLink() {
const url = shareUrl()
if (!url) return
navigator.clipboard
.writeText(url)
.then(() => {
if (state.timer) window.clearTimeout(state.timer)
setState("copied", true)
const timer = window.setTimeout(() => {
setState("copied", false)
setState("timer", undefined)
}, 3000)
setState("timer", timer)
})
.catch((error) => {
console.error("Failed to copy share link", error)
})
}
function viewShare() {
const url = shareUrl()
if (!url) return
platform.openLink(url)
}
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
return ( return (
<> <>
<Show when={leftMount()}> <Show when={centerMount()}>
{(mount) => ( {(mount) => (
<Portal mount={mount()}> <Portal mount={mount()}>
<button <button
@@ -406,25 +382,23 @@ export function SessionHeader() {
setPrefs("app", value as OpenApp) setPrefs("app", value as OpenApp)
}} }}
> >
<For each={options()}> {options().map((o) => (
{(o) => ( <DropdownMenu.RadioItem
<DropdownMenu.RadioItem value={o.id}
value={o.id} onSelect={() => {
onSelect={() => { setMenu("open", false)
setMenu("open", false) openDir(o.id)
openDir(o.id) }}
}} >
> <div class="flex size-5 shrink-0 items-center justify-center">
<div class="flex size-5 shrink-0 items-center justify-center"> <AppIcon id={o.icon} class={size(o.icon)} />
<AppIcon id={o.icon} class={openIconSize(o.icon)} /> </div>
</div> <DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel> <DropdownMenu.ItemIndicator>
<DropdownMenu.ItemIndicator> <Icon name="check-small" size="small" class="text-icon-weak" />
<Icon name="check-small" size="small" class="text-icon-weak" /> </DropdownMenu.ItemIndicator>
</DropdownMenu.ItemIndicator> </DropdownMenu.RadioItem>
</DropdownMenu.RadioItem> ))}
)}
</For>
</DropdownMenu.RadioGroup> </DropdownMenu.RadioGroup>
</DropdownMenu.Group> </DropdownMenu.Group>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
@@ -454,7 +428,7 @@ export function SessionHeader() {
<Popover <Popover
title={language.t("session.share.popover.title")} title={language.t("session.share.popover.title")}
description={ description={
share.shareUrl() shareUrl()
? language.t("session.share.popover.description.shared") ? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared") : language.t("session.share.popover.description.unshared")
} }
@@ -467,24 +441,24 @@ export function SessionHeader() {
variant: "ghost", variant: "ghost",
class: class:
"rounded-md h-[24px] px-3 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active", "rounded-md h-[24px] px-3 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
classList: { "rounded-r-none": share.shareUrl() !== undefined }, classList: { "rounded-r-none": shareUrl() !== undefined },
style: { scale: 1 }, style: { scale: 1 },
}} }}
trigger={language.t("session.share.action.share")} trigger={language.t("session.share.action.share")}
> >
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<Show <Show
when={share.shareUrl()} when={shareUrl()}
fallback={ fallback={
<div class="flex"> <div class="flex">
<Button <Button
size="large" size="large"
variant="primary" variant="primary"
class="w-1/2" class="w-1/2"
onClick={share.shareSession} onClick={shareSession}
disabled={share.state.share} disabled={state.share}
> >
{share.state.share {state.share
? language.t("session.share.action.publishing") ? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")} : language.t("session.share.action.publish")}
</Button> </Button>
@@ -493,7 +467,7 @@ export function SessionHeader() {
> >
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<TextField <TextField
value={share.shareUrl() ?? ""} value={shareUrl() ?? ""}
readOnly readOnly
copyable copyable
copyKind="link" copyKind="link"
@@ -505,10 +479,10 @@ export function SessionHeader() {
size="large" size="large"
variant="secondary" variant="secondary"
class="w-full shadow-none border border-border-weak-base" class="w-full shadow-none border border-border-weak-base"
onClick={share.unshareSession} onClick={unshareSession}
disabled={share.state.unshare} disabled={state.unshare}
> >
{share.state.unshare {state.unshare
? language.t("session.share.action.unpublishing") ? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")} : language.t("session.share.action.unpublish")}
</Button> </Button>
@@ -516,8 +490,8 @@ export function SessionHeader() {
size="large" size="large"
variant="primary" variant="primary"
class="w-full" class="w-full"
onClick={share.viewShare} onClick={viewShare}
disabled={share.state.unshare} disabled={state.unshare}
> >
{language.t("session.share.action.view")} {language.t("session.share.action.view")}
</Button> </Button>
@@ -526,10 +500,10 @@ export function SessionHeader() {
</Show> </Show>
</div> </div>
</Popover> </Popover>
<Show when={share.shareUrl()} fallback={<div aria-hidden="true" />}> <Show when={shareUrl()} fallback={<div aria-hidden="true" />}>
<Tooltip <Tooltip
value={ value={
share.state.copied state.copied
? language.t("session.share.copy.copied") ? language.t("session.share.copy.copied")
: language.t("session.share.copy.copyLink") : language.t("session.share.copy.copyLink")
} }
@@ -537,13 +511,13 @@ export function SessionHeader() {
gutter={8} gutter={8}
> >
<IconButton <IconButton
icon={share.state.copied ? "check" : "link"} icon={state.copied ? "check" : "link"}
variant="ghost" variant="ghost"
class="rounded-l-none h-[24px] border border-border-base bg-surface-panel shadow-none" class="rounded-l-none h-[24px] border border-border-base bg-surface-panel shadow-none"
onClick={() => share.copyLink((error) => showRequestError(language, error))} onClick={copyLink}
disabled={share.state.unshare} disabled={state.unshare}
aria-label={ aria-label={
share.state.copied state.copied
? language.t("session.share.copy.copied") ? language.t("session.share.copy.copied")
: language.t("session.share.copy.copyLink") : language.t("session.share.copy.copyLink")
} }
@@ -552,7 +526,7 @@ export function SessionHeader() {
</Show> </Show>
</div> </div>
</Show> </Show>
<div class="flex items-center gap-3 ml-2 shrink-0"> <div class="hidden md:flex items-center gap-3 ml-2 shrink-0">
<TooltipKeybind <TooltipKeybind
title={language.t("command.terminal.toggle")} title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")} keybind={command.keybind("terminal.toggle")}
@@ -585,7 +559,7 @@ export function SessionHeader() {
</Button> </Button>
</TooltipKeybind> </TooltipKeybind>
</div> </div>
<div class="hidden lg:block shrink-0"> <div class="hidden md:block shrink-0">
<TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}> <TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
<Button <Button
variant="ghost" variant="ghost"
@@ -615,7 +589,7 @@ export function SessionHeader() {
</Button> </Button>
</TooltipKeybind> </TooltipKeybind>
</div> </div>
<div class="hidden lg:block shrink-0"> <div class="hidden md:block shrink-0">
<TooltipKeybind <TooltipKeybind
title={language.t("command.fileTree.toggle")} title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")} keybind={command.keybind("fileTree.toggle")}
@@ -8,8 +8,6 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
const MAIN_WORKTREE = "main" const MAIN_WORKTREE = "main"
const CREATE_WORKTREE = "create" const CREATE_WORKTREE = "create"
const ROOT_CLASS =
"size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]"
interface NewSessionViewProps { interface NewSessionViewProps {
worktree: string worktree: string
@@ -49,7 +47,7 @@ export function NewSessionView(props: NewSessionViewProps) {
} }
return ( return (
<div class={ROOT_CLASS}> <div class="size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]">
<div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div> <div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div>
<div class="flex justify-center items-center gap-3"> <div class="flex justify-center items-center gap-3">
<Icon name="folder" size="small" /> <Icon name="folder" size="small" />
@@ -31,12 +31,8 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
const command = useCommand() const command = useCommand()
const sortable = createSortable(props.tab) const sortable = createSortable(props.tab)
const path = createMemo(() => file.pathFromTab(props.tab)) const path = createMemo(() => file.pathFromTab(props.tab))
const content = createMemo(() => {
const value = path()
if (!value) return
return <FileVisual path={value} />
})
return ( return (
// @ts-ignore
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}> <div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
<div class="relative h-full"> <div class="relative h-full">
<Tabs.Trigger <Tabs.Trigger
@@ -59,7 +55,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
hideCloseButton hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)} onMiddleClick={() => props.onTabClose(props.tab)}
> >
<Show when={content()}>{(value) => value()}</Show> <Show when={path()}>{(p) => <FileVisual path={p()} />}</Show>
</Tabs.Trigger> </Tabs.Trigger>
</div> </div>
</div> </div>
@@ -1,5 +1,5 @@
import type { JSX } from "solid-js" import type { JSX } from "solid-js"
import { Show, createEffect, onCleanup } from "solid-js" import { Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createSortable } from "@thisbeyond/solid-dnd" import { createSortable } from "@thisbeyond/solid-dnd"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -20,8 +20,6 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
menuPosition: { x: 0, y: 0 }, menuPosition: { x: 0, y: 0 },
blurEnabled: false, blurEnabled: false,
}) })
let input: HTMLInputElement | undefined
let blurFrame: number | undefined
const isDefaultTitle = () => { const isDefaultTitle = () => {
const number = props.terminal.titleNumber const number = props.terminal.titleNumber
@@ -79,6 +77,13 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
setStore("blurEnabled", false) setStore("blurEnabled", false)
setStore("title", props.terminal.title) setStore("title", props.terminal.title)
setStore("editing", true) setStore("editing", true)
setTimeout(() => {
const input = document.getElementById(`terminal-title-input-${props.terminal.id}`) as HTMLInputElement
if (!input) return
input.focus()
input.select()
setTimeout(() => setStore("blurEnabled", true), 100)
}, 10)
} }
const save = () => { const save = () => {
@@ -109,25 +114,9 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
setStore("menuOpen", true) setStore("menuOpen", true)
} }
createEffect(() => {
if (!store.editing) return
if (!input) return
input.focus()
input.select()
if (blurFrame !== undefined) cancelAnimationFrame(blurFrame)
blurFrame = requestAnimationFrame(() => {
blurFrame = undefined
setStore("blurEnabled", true)
})
})
onCleanup(() => {
if (blurFrame === undefined) return
cancelAnimationFrame(blurFrame)
})
return ( return (
<div <div
// @ts-ignore
use:sortable use:sortable
class="outline-none focus:outline-none focus-visible:outline-none" class="outline-none focus:outline-none focus-visible:outline-none"
classList={{ classList={{
@@ -164,7 +153,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
<Show when={store.editing}> <Show when={store.editing}>
<div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto"> <div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto">
<input <input
ref={input} id={`terminal-title-input-${props.terminal.id}`}
type="text" type="text"
value={store.title} value={store.title}
onInput={(e) => setStore("title", e.currentTarget.value)} onInput={(e) => setStore("title", e.currentTarget.value)}
@@ -2,7 +2,6 @@ import { Component } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
export const SettingsAgents: Component = () => { export const SettingsAgents: Component = () => {
// TODO: Replace this placeholder with full agents settings controls.
const language = useLanguage() const language = useLanguage()
return ( return (
@@ -2,7 +2,6 @@ import { Component } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
export const SettingsCommands: Component = () => { export const SettingsCommands: Component = () => {
// TODO: Replace this placeholder with full commands settings controls.
const language = useLanguage() const language = useLanguage()
return ( return (
+270 -288
View File
@@ -1,4 +1,4 @@
import { Component, Show, createMemo, createResource, type JSX } from "solid-js" import { Component, Show, createEffect, createMemo, createResource, type JSX } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
@@ -128,294 +128,11 @@ export const SettingsGeneral: Component = () => {
{ value: "roboto-mono", label: "font.option.robotoMono" }, { value: "roboto-mono", label: "font.option.robotoMono" },
{ value: "source-code-pro", label: "font.option.sourceCodePro" }, { value: "source-code-pro", label: "font.option.sourceCodePro" },
{ value: "ubuntu-mono", label: "font.option.ubuntuMono" }, { value: "ubuntu-mono", label: "font.option.ubuntuMono" },
{ value: "geist-mono", label: "font.option.geistMono" },
] as const ] as const
const fontOptionsList = [...fontOptions] const fontOptionsList = [...fontOptions]
const soundOptions = [...SOUND_OPTIONS] const soundOptions = [...SOUND_OPTIONS]
const soundSelectProps = (current: () => string, set: (id: string) => void) => ({
options: soundOptions,
current: soundOptions.find((o) => o.id === current()),
value: (o: (typeof soundOptions)[number]) => o.id,
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
playDemoSound(option.src)
},
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
set(option.id)
playDemoSound(option.src)
},
variant: "secondary" as const,
size: "small" as const,
triggerVariant: "settings" as const,
})
const AppearanceSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.general.row.language.title")}
description={language.t("settings.general.row.language.description")}
>
<Select
data-action="settings-language"
options={languageOptions()}
current={languageOptions().find((o) => o.value === language.locale())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && language.setLocale(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.appearance.title")}
description={language.t("settings.general.row.appearance.description")}
>
<Select
data-action="settings-color-scheme"
options={colorSchemeOptions()}
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
onHighlight={(option) => {
if (!option) return
theme.previewColorScheme(option.value)
return () => theme.cancelPreview()
}}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.theme.title")}
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
</>
}
>
<Select
data-action="settings-theme"
options={themeOptions()}
current={themeOptions().find((o) => o.id === theme.themeId())}
value={(o) => o.id}
label={(o) => o.name}
onSelect={(option) => {
if (!option) return
theme.setTheme(option.id)
}}
onHighlight={(option) => {
if (!option) return
theme.previewTheme(option.id)
return () => theme.cancelPreview()
}}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.font.title")}
description={language.t("settings.general.row.font.description")}
>
<Select
data-action="settings-font"
options={fontOptionsList}
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
value={(o) => o.value}
label={(o) => language.t(o.label)}
onSelect={(option) => option && settings.appearance.setFont(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
>
{(option) => (
<span style={{ "font-family": monoFontFamily(option?.value) }}>
{option ? language.t(option.label) : ""}
</span>
)}
</Select>
</SettingsRow>
</div>
</div>
)
const NotificationsSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.general.notifications.agent.title")}
description={language.t("settings.general.notifications.agent.description")}
>
<div data-action="settings-notifications-agent">
<Switch
checked={settings.notifications.agent()}
onChange={(checked) => settings.notifications.setAgent(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.notifications.permissions.title")}
description={language.t("settings.general.notifications.permissions.description")}
>
<div data-action="settings-notifications-permissions">
<Switch
checked={settings.notifications.permissions()}
onChange={(checked) => settings.notifications.setPermissions(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.notifications.errors.title")}
description={language.t("settings.general.notifications.errors.description")}
>
<div data-action="settings-notifications-errors">
<Switch
checked={settings.notifications.errors()}
onChange={(checked) => settings.notifications.setErrors(checked)}
/>
</div>
</SettingsRow>
</div>
</div>
)
const SoundsSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.general.sounds.agent.title")}
description={language.t("settings.general.sounds.agent.description")}
>
<div class="flex items-center gap-2">
<div data-action="settings-sounds-agent-enabled">
<Switch
checked={settings.sounds.agentEnabled()}
onChange={(checked) => settings.sounds.setAgentEnabled(checked)}
/>
</div>
<Select
disabled={!settings.sounds.agentEnabled()}
data-action="settings-sounds-agent"
{...soundSelectProps(
() => settings.sounds.agent(),
(id) => settings.sounds.setAgent(id),
)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.permissions.title")}
description={language.t("settings.general.sounds.permissions.description")}
>
<div class="flex items-center gap-2">
<div data-action="settings-sounds-permissions-enabled">
<Switch
checked={settings.sounds.permissionsEnabled()}
onChange={(checked) => settings.sounds.setPermissionsEnabled(checked)}
/>
</div>
<Select
disabled={!settings.sounds.permissionsEnabled()}
data-action="settings-sounds-permissions"
{...soundSelectProps(
() => settings.sounds.permissions(),
(id) => settings.sounds.setPermissions(id),
)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.errors.title")}
description={language.t("settings.general.sounds.errors.description")}
>
<div class="flex items-center gap-2">
<div data-action="settings-sounds-errors-enabled">
<Switch
checked={settings.sounds.errorsEnabled()}
onChange={(checked) => settings.sounds.setErrorsEnabled(checked)}
/>
</div>
<Select
disabled={!settings.sounds.errorsEnabled()}
data-action="settings-sounds-errors"
{...soundSelectProps(
() => settings.sounds.errors(),
(id) => settings.sounds.setErrors(id),
)}
/>
</div>
</SettingsRow>
</div>
</div>
)
const UpdatesSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.updates.row.startup.title")}
description={language.t("settings.updates.row.startup.description")}
>
<div data-action="settings-updates-startup">
<Switch
checked={settings.updates.startup()}
disabled={!platform.checkUpdate}
onChange={(checked) => settings.updates.setStartup(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.releaseNotes.title")}
description={language.t("settings.general.row.releaseNotes.description")}
>
<div data-action="settings-release-notes">
<Switch
checked={settings.general.releaseNotes()}
onChange={(checked) => settings.general.setReleaseNotes(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")}
>
<Button size="small" variant="secondary" disabled={store.checking || !platform.checkUpdate} onClick={check}>
{store.checking
? language.t("settings.updates.action.checking")
: language.t("settings.updates.action.checkNow")}
</Button>
</SettingsRow>
</div>
</div>
)
return ( return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"> <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]"> <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
@@ -425,11 +142,230 @@ export const SettingsGeneral: Component = () => {
</div> </div>
<div class="flex flex-col gap-8 w-full"> <div class="flex flex-col gap-8 w-full">
<AppearanceSection /> {/* Appearance Section */}
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
<NotificationsSection /> <div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.general.row.language.title")}
description={language.t("settings.general.row.language.description")}
>
<Select
data-action="settings-language"
options={languageOptions()}
current={languageOptions().find((o) => o.value === language.locale())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && language.setLocale(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<SoundsSection /> <SettingsRow
title={language.t("settings.general.row.appearance.title")}
description={language.t("settings.general.row.appearance.description")}
>
<Select
data-action="settings-color-scheme"
options={colorSchemeOptions()}
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
onHighlight={(option) => {
if (!option) return
theme.previewColorScheme(option.value)
return () => theme.cancelPreview()
}}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.theme.title")}
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
</>
}
>
<Select
data-action="settings-theme"
options={themeOptions()}
current={themeOptions().find((o) => o.id === theme.themeId())}
value={(o) => o.id}
label={(o) => o.name}
onSelect={(option) => {
if (!option) return
theme.setTheme(option.id)
}}
onHighlight={(option) => {
if (!option) return
theme.previewTheme(option.id)
return () => theme.cancelPreview()
}}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.font.title")}
description={language.t("settings.general.row.font.description")}
>
<Select
data-action="settings-font"
options={fontOptionsList}
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
value={(o) => o.value}
label={(o) => language.t(o.label)}
onSelect={(option) => option && settings.appearance.setFont(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
>
{(option) => (
<span style={{ "font-family": monoFontFamily(option?.value) }}>
{option ? language.t(option.label) : ""}
</span>
)}
</Select>
</SettingsRow>
</div>
</div>
{/* System notifications Section */}
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.general.notifications.agent.title")}
description={language.t("settings.general.notifications.agent.description")}
>
<div data-action="settings-notifications-agent">
<Switch
checked={settings.notifications.agent()}
onChange={(checked) => settings.notifications.setAgent(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.notifications.permissions.title")}
description={language.t("settings.general.notifications.permissions.description")}
>
<div data-action="settings-notifications-permissions">
<Switch
checked={settings.notifications.permissions()}
onChange={(checked) => settings.notifications.setPermissions(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.notifications.errors.title")}
description={language.t("settings.general.notifications.errors.description")}
>
<div data-action="settings-notifications-errors">
<Switch
checked={settings.notifications.errors()}
onChange={(checked) => settings.notifications.setErrors(checked)}
/>
</div>
</SettingsRow>
</div>
</div>
{/* Sound effects Section */}
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.general.sounds.agent.title")}
description={language.t("settings.general.sounds.agent.description")}
>
<Select
data-action="settings-sounds-agent"
options={soundOptions}
current={soundOptions.find((o) => o.id === settings.sounds.agent())}
value={(o) => o.id}
label={(o) => language.t(o.label)}
onHighlight={(option) => {
if (!option) return
playDemoSound(option.src)
}}
onSelect={(option) => {
if (!option) return
settings.sounds.setAgent(option.id)
playDemoSound(option.src)
}}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.permissions.title")}
description={language.t("settings.general.sounds.permissions.description")}
>
<Select
data-action="settings-sounds-permissions"
options={soundOptions}
current={soundOptions.find((o) => o.id === settings.sounds.permissions())}
value={(o) => o.id}
label={(o) => language.t(o.label)}
onHighlight={(option) => {
if (!option) return
playDemoSound(option.src)
}}
onSelect={(option) => {
if (!option) return
settings.sounds.setPermissions(option.id)
playDemoSound(option.src)
}}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.errors.title")}
description={language.t("settings.general.sounds.errors.description")}
>
<Select
data-action="settings-sounds-errors"
options={soundOptions}
current={soundOptions.find((o) => o.id === settings.sounds.errors())}
value={(o) => o.id}
label={(o) => language.t(o.label)}
onHighlight={(option) => {
if (!option) return
playDemoSound(option.src)
}}
onSelect={(option) => {
if (!option) return
settings.sounds.setErrors(option.id)
playDemoSound(option.src)
}}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
</div>
</div>
<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}> <Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
{(_) => { {(_) => {
@@ -459,7 +395,53 @@ export const SettingsGeneral: Component = () => {
}} }}
</Show> </Show>
<UpdatesSection /> {/* Updates Section */}
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.updates.row.startup.title")}
description={language.t("settings.updates.row.startup.description")}
>
<div data-action="settings-updates-startup">
<Switch
checked={settings.updates.startup()}
disabled={!platform.checkUpdate}
onChange={(checked) => settings.updates.setStartup(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.releaseNotes.title")}
description={language.t("settings.general.row.releaseNotes.description")}
>
<div data-action="settings-release-notes">
<Switch
checked={settings.general.releaseNotes()}
onChange={(checked) => settings.general.setReleaseNotes(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")}
>
<Button
size="small"
variant="secondary"
disabled={store.checking || !platform.checkUpdate}
onClick={check}
>
{store.checking
? language.t("settings.updates.action.checking")
: language.t("settings.updates.action.checkNow")}
</Button>
</SettingsRow>
</div>
</div>
<Show when={linux()}> <Show when={linux()}>
{(_) => { {(_) => {
+143 -160
View File
@@ -21,9 +21,6 @@ type KeybindMeta = {
group: KeybindGroup group: KeybindGroup
} }
type KeybindMap = Record<string, string | undefined>
type CommandContext = ReturnType<typeof useCommand>
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"] const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
type GroupKey = type GroupKey =
@@ -110,150 +107,6 @@ function signatures(config: string | undefined) {
return sigs return sigs
} }
function keybinds(value: unknown): KeybindMap {
if (!value || typeof value !== "object" || Array.isArray(value)) return {}
return value as KeybindMap
}
function listFor(command: CommandContext, map: KeybindMap, palette: string) {
const out = new Map<string, KeybindMeta>()
out.set(PALETTE_ID, { title: palette, group: "General" })
for (const opt of command.catalog) {
if (opt.id.startsWith("suggested.")) continue
out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
}
for (const opt of command.options) {
if (opt.id.startsWith("suggested.")) continue
out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
}
for (const [id, value] of Object.entries(map)) {
if (typeof value !== "string") continue
if (out.has(id)) continue
out.set(id, { title: id, group: groupFor(id) })
}
return out
}
function groupedFor(list: Map<string, KeybindMeta>) {
const out = new Map<KeybindGroup, string[]>()
for (const group of GROUPS) out.set(group, [])
for (const [id, item] of list) {
const ids = out.get(item.group)
if (!ids) continue
ids.push(id)
}
for (const group of GROUPS) {
const ids = out.get(group)
if (!ids) continue
ids.sort((a, b) => (list.get(a)?.title ?? "").localeCompare(list.get(b)?.title ?? ""))
}
return out
}
function filteredFor(
query: string,
list: Map<string, KeybindMeta>,
grouped: Map<KeybindGroup, string[]>,
keybind: (id: string) => string,
) {
const value = query.toLowerCase().trim()
if (!value) return grouped
const out = new Map<KeybindGroup, string[]>()
for (const group of GROUPS) out.set(group, [])
const items = Array.from(list.entries()).map(([id, meta]) => ({
id,
title: meta.title,
group: meta.group,
keybind: keybind(id),
}))
const results = fuzzysort.go(value, items, {
keys: ["title", "keybind"],
threshold: -10000,
})
for (const result of results) {
const ids = out.get(result.obj.group)
if (!ids) continue
ids.push(result.obj.id)
}
return out
}
function useKeyCapture(input: {
active: () => string | null
stop: () => void
set: (id: string, keybind: string) => void
used: () => Map<string, { id: string; title: string }[]>
language: ReturnType<typeof useLanguage>
}) {
onMount(() => {
const handle = (event: KeyboardEvent) => {
const id = input.active()
if (!id) return
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
if (event.key === "Escape") {
input.stop()
return
}
const clear =
(event.key === "Backspace" || event.key === "Delete") &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey
if (clear) {
input.set(id, "none")
input.stop()
return
}
const next = recordKeybind(event)
if (!next) return
const conflicts = new Map<string, string>()
for (const sig of signatures(next)) {
for (const item of input.used().get(sig) ?? []) {
if (item.id === id) continue
conflicts.set(item.id, item.title)
}
}
if (conflicts.size > 0) {
showToast({
title: input.language.t("settings.shortcuts.conflict.title"),
description: input.language.t("settings.shortcuts.conflict.description", {
keybind: formatKeybind(next),
titles: [...conflicts.values()].join(", "),
}),
})
return
}
input.set(id, next)
input.stop()
}
document.addEventListener("keydown", handle, true)
onCleanup(() => document.removeEventListener("keydown", handle, true))
})
}
export const SettingsKeybinds: Component = () => { export const SettingsKeybinds: Component = () => {
const command = useCommand() const command = useCommand()
const language = useLanguage() const language = useLanguage()
@@ -282,9 +135,11 @@ export const SettingsKeybinds: Component = () => {
command.keybinds(false) command.keybinds(false)
} }
const map = createMemo(() => keybinds(settings.current.keybinds)) const hasOverrides = createMemo(() => {
const keybinds = settings.current.keybinds as Record<string, string | undefined> | undefined
const hasOverrides = createMemo(() => Object.values(map()).some((x) => typeof x === "string")) if (!keybinds) return false
return Object.values(keybinds).some((x) => typeof x === "string")
})
const resetAll = () => { const resetAll = () => {
stop() stop()
@@ -297,15 +152,88 @@ export const SettingsKeybinds: Component = () => {
const list = createMemo(() => { const list = createMemo(() => {
language.locale() language.locale()
return listFor(command, map(), language.t("command.palette")) const out = new Map<string, KeybindMeta>()
out.set(PALETTE_ID, { title: language.t("command.palette"), group: "General" })
for (const opt of command.catalog) {
if (opt.id.startsWith("suggested.")) continue
out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
}
for (const opt of command.options) {
if (opt.id.startsWith("suggested.")) continue
out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
}
const keybinds = settings.current.keybinds as Record<string, string | undefined> | undefined
if (keybinds) {
for (const [id, value] of Object.entries(keybinds)) {
if (typeof value !== "string") continue
if (out.has(id)) continue
out.set(id, { title: id, group: groupFor(id) })
}
}
return out
}) })
const title = (id: string) => list().get(id)?.title ?? "" const title = (id: string) => list().get(id)?.title ?? ""
const grouped = createMemo(() => groupedFor(list())) const grouped = createMemo(() => {
const map = list()
const out = new Map<KeybindGroup, string[]>()
for (const group of GROUPS) out.set(group, [])
for (const [id, item] of map) {
const ids = out.get(item.group)
if (!ids) continue
ids.push(id)
}
for (const group of GROUPS) {
const ids = out.get(group)
if (!ids) continue
ids.sort((a, b) => {
const at = map.get(a)?.title ?? ""
const bt = map.get(b)?.title ?? ""
return at.localeCompare(bt)
})
}
return out
})
const filtered = createMemo(() => { const filtered = createMemo(() => {
return filteredFor(store.filter, list(), grouped(), (id) => command.keybind(id) || "") const query = store.filter.toLowerCase().trim()
if (!query) return grouped()
const map = list()
const out = new Map<KeybindGroup, string[]>()
for (const group of GROUPS) out.set(group, [])
const items = Array.from(map.entries()).map(([id, meta]) => ({
id,
title: meta.title,
group: meta.group,
keybind: command.keybind(id) || "",
}))
const results = fuzzysort.go(query, items, {
keys: ["title", "keybind"],
threshold: -10000,
})
for (const result of results) {
const item = result.obj
const ids = out.get(item.group)
if (!ids) continue
ids.push(item.id)
}
return out
}) })
const hasResults = createMemo(() => { const hasResults = createMemo(() => {
@@ -354,14 +282,69 @@ export const SettingsKeybinds: Component = () => {
return map return map
}) })
const setKeybind = (id: string, keybind: string) => settings.keybinds.set(id, keybind) const setKeybind = (id: string, keybind: string) => {
settings.keybinds.set(id, keybind)
}
useKeyCapture({ onMount(() => {
active: () => store.active, const handle = (event: KeyboardEvent) => {
stop, const id = store.active
set: setKeybind, if (!id) return
used,
language, event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
if (event.key === "Escape") {
stop()
return
}
const clear =
(event.key === "Backspace" || event.key === "Delete") &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey
if (clear) {
setKeybind(id, "none")
stop()
return
}
const next = recordKeybind(event)
if (!next) return
const map = used()
const conflicts = new Map<string, string>()
for (const sig of signatures(next)) {
const list = map.get(sig) ?? []
for (const item of list) {
if (item.id === id) continue
conflicts.set(item.id, item.title)
}
}
if (conflicts.size > 0) {
showToast({
title: language.t("settings.shortcuts.conflict.title"),
description: language.t("settings.shortcuts.conflict.description", {
keybind: formatKeybind(next),
titles: [...conflicts.values()].join(", "),
}),
})
return
}
setKeybind(id, next)
stop()
}
document.addEventListener("keydown", handle, true)
onCleanup(() => {
document.removeEventListener("keydown", handle, true)
})
}) })
onCleanup(() => { onCleanup(() => {
@@ -2,7 +2,6 @@ import { Component } from "solid-js"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
export const SettingsMcp: Component = () => { export const SettingsMcp: Component = () => {
// TODO: Replace this placeholder with full MCP settings controls.
const language = useLanguage() const language = useLanguage()
return ( return (
+14 -21
View File
@@ -12,25 +12,6 @@ import { popularProviders } from "@/hooks/use-providers"
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number] type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
const ListLoadingState: Component<{ label: string }> = (props) => {
return (
<div class="flex flex-col items-center justify-center py-12 text-center">
<span class="text-14-regular text-text-weak">{props.label}</span>
</div>
)
}
const ListEmptyState: Component<{ message: string; filter: string }> = (props) => {
return (
<div class="flex flex-col items-center justify-center py-12 text-center">
<span class="text-14-regular text-text-weak">{props.message}</span>
<Show when={props.filter}>
<span class="text-14-regular text-text-strong mt-1">&quot;{props.filter}&quot;</span>
</Show>
</div>
)
}
export const SettingsModels: Component = () => { export const SettingsModels: Component = () => {
const language = useLanguage() const language = useLanguage()
const models = useModels() const models = useModels()
@@ -87,12 +68,24 @@ export const SettingsModels: Component = () => {
<Show <Show
when={!list.grouped.loading} when={!list.grouped.loading}
fallback={ fallback={
<ListLoadingState label={`${language.t("common.loading")}${language.t("common.loading.ellipsis")}`} /> <div class="flex flex-col items-center justify-center py-12 text-center">
<span class="text-14-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</span>
</div>
} }
> >
<Show <Show
when={list.flat().length > 0} when={list.flat().length > 0}
fallback={<ListEmptyState message={language.t("dialog.model.empty")} filter={list.filter()} />} fallback={
<div class="flex flex-col items-center justify-center py-12 text-center">
<span class="text-14-regular text-text-weak">{language.t("dialog.model.empty")}</span>
<Show when={list.filter()}>
<span class="text-14-regular text-text-strong mt-1">&quot;{list.filter()}&quot;</span>
</Show>
</div>
}
> >
<For each={list.grouped.latest}> <For each={list.grouped.latest}>
{(group) => ( {(group) => (
@@ -165,14 +165,12 @@ export const SettingsPermissions: Component = () => {
const nextValue = const nextValue =
existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action
const rollback = (err: unknown) => { globalSync.set("config", "permission", { ...map, [id]: nextValue })
globalSync.updateConfig({ permission: { [id]: nextValue } }).catch((err: unknown) => {
globalSync.set("config", "permission", before) globalSync.set("config", "permission", before)
const message = err instanceof Error ? err.message : String(err) const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("settings.permissions.toast.updateFailed.title"), description: message }) showToast({ title: language.t("settings.permissions.toast.updateFailed.title"), description: message })
} })
globalSync.set("config", "permission", { ...map, [id]: nextValue })
globalSync.updateConfig({ permission: { [id]: nextValue } }).catch(rollback)
} }
return ( return (
@@ -14,17 +14,7 @@ import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogCustomProvider } from "./dialog-custom-provider" import { DialogCustomProvider } from "./dialog-custom-provider"
type ProviderSource = "env" | "api" | "config" | "custom" type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number] type ProviderMeta = { source?: ProviderSource }
const PROVIDER_NOTES = [
{ match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" },
{ match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" },
{ match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" },
{ match: (id: string) => id === "openai", key: "dialog.provider.openai.note" },
{ match: (id: string) => id === "google", key: "dialog.provider.google.note" },
{ match: (id: string) => id === "openrouter", key: "dialog.provider.openrouter.note" },
{ match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" },
] as const
export const SettingsProviders: Component = () => { export const SettingsProviders: Component = () => {
const dialog = useDialog() const dialog = useDialog()
@@ -54,28 +44,22 @@ export const SettingsProviders: Component = () => {
return items return items
}) })
const source = (item: ProviderItem): ProviderSource | undefined => { const source = (item: unknown) => (item as ProviderMeta).source
if (!("source" in item)) return
const value = item.source
if (value === "env" || value === "api" || value === "config" || value === "custom") return value
return
}
const type = (item: ProviderItem) => { const type = (item: unknown) => {
const current = source(item) const current = source(item)
if (current === "env") return language.t("settings.providers.tag.environment") if (current === "env") return language.t("settings.providers.tag.environment")
if (current === "api") return language.t("provider.connect.method.apiKey") if (current === "api") return language.t("provider.connect.method.apiKey")
if (current === "config") { if (current === "config") {
if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom") const id = (item as { id?: string }).id
if (id && isConfigCustom(id)) return language.t("settings.providers.tag.custom")
return language.t("settings.providers.tag.config") return language.t("settings.providers.tag.config")
} }
if (current === "custom") return language.t("settings.providers.tag.custom") if (current === "custom") return language.t("settings.providers.tag.custom")
return language.t("settings.providers.tag.other") return language.t("settings.providers.tag.other")
} }
const canDisconnect = (item: ProviderItem) => source(item) !== "env" const canDisconnect = (item: unknown) => source(item) !== "env"
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const isConfigCustom = (providerID: string) => { const isConfigCustom = (providerID: string) => {
const provider = globalSync.data.config.provider?.[providerID] const provider = globalSync.data.config.provider?.[providerID]
@@ -191,8 +175,40 @@ export const SettingsProviders: Component = () => {
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag> <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show> </Show>
</div> </div>
<Show when={note(item.id)}> <Show when={item.id === "opencode"}>
{(key) => <span class="text-12-regular text-text-weak pl-8">{language.t(key())}</span>} <span class="text-12-regular text-text-weak pl-8">
{language.t("dialog.provider.opencode.note")}
</span>
</Show>
<Show when={item.id === "anthropic"}>
<span class="text-12-regular text-text-weak pl-8">
{language.t("dialog.provider.anthropic.note")}
</span>
</Show>
<Show when={item.id.startsWith("github-copilot")}>
<span class="text-12-regular text-text-weak pl-8">
{language.t("dialog.provider.copilot.note")}
</span>
</Show>
<Show when={item.id === "openai"}>
<span class="text-12-regular text-text-weak pl-8">
{language.t("dialog.provider.openai.note")}
</span>
</Show>
<Show when={item.id === "google"}>
<span class="text-12-regular text-text-weak pl-8">
{language.t("dialog.provider.google.note")}
</span>
</Show>
<Show when={item.id === "openrouter"}>
<span class="text-12-regular text-text-weak pl-8">
{language.t("dialog.provider.openrouter.note")}
</span>
</Show>
<Show when={item.id === "vercel"}>
<span class="text-12-regular text-text-weak pl-8">
{language.t("dialog.provider.vercel.note")}
</span>
</Show> </Show>
</div> </div>
<Button <Button
+135 -171
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, For, onCleanup, Show, type Accessor, type JSXElement } from "solid-js" import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store" import { createStore, reconcile } from "solid-js/store"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -7,151 +7,16 @@ import { Tabs } from "@opencode-ai/ui/tabs"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, useServer } from "@/context/server" import { normalizeServerUrl, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { DialogSelectServer } from "./dialog-select-server" import { DialogSelectServer } from "./dialog-select-server"
import { showToast } from "@opencode-ai/ui/toast"
import { ServerRow } from "@/components/server/server-row" import { ServerRow } from "@/components/server/server-row"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health" import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
const pollMs = 10_000
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
if (parts.length === 1) return value
return (
<>
{parts[0]}
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
{parts.slice(1).join(file)}
</>
)
}
const listServersByHealth = (
list: string[],
active: string | undefined,
status: Record<string, ServerHealth | undefined>,
) => {
if (!list.length) return list
const order = new Map(list.map((url, index) => [url, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff = rank(status[a]) - rank(status[b])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
}
const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) => {
const [status, setStatus] = createStore({} as Record<string, ServerHealth | undefined>)
createEffect(() => {
const list = servers()
let dead = false
const refresh = async () => {
const results: Record<string, ServerHealth> = {}
await Promise.all(
list.map(async (url) => {
results[url] = await checkServerHealth(url, fetcher)
}),
)
if (dead) return
setStatus(reconcile(results))
}
void refresh()
const id = setInterval(() => void refresh(), pollMs)
onCleanup(() => {
dead = true
clearInterval(id)
})
})
return status
}
const useDefaultServerUrl = (
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
) => {
const [url, setUrl] = createSignal<string | undefined>()
const [tick, setTick] = createSignal(0)
createEffect(() => {
tick()
let dead = false
const result = get?.()
if (!result) {
setUrl(undefined)
onCleanup(() => {
dead = true
})
return
}
if (result instanceof Promise) {
void result.then((next) => {
if (dead) return
setUrl(next ? normalizeServerUrl(next) : undefined)
})
onCleanup(() => {
dead = true
})
return
}
setUrl(normalizeServerUrl(result))
onCleanup(() => {
dead = true
})
})
return { url, refresh: () => setTick((value) => value + 1) }
}
const useMcpToggle = (input: {
sync: ReturnType<typeof useSync>
sdk: ReturnType<typeof useSDK>
language: ReturnType<typeof useLanguage>
}) => {
const [loading, setLoading] = createSignal<string | null>(null)
const toggle = async (name: string) => {
if (loading()) return
setLoading(name)
try {
const status = input.sync.data.mcp[name]
await (status?.status === "connected"
? input.sdk.client.mcp.disconnect({ name })
: input.sdk.client.mcp.connect({ name }))
const result = await input.sdk.client.mcp.status()
if (result.data) input.sync.set("mcp", result.data)
} catch (err) {
showToast({
variant: "error",
title: input.language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
} finally {
setLoading(null)
}
}
return { loading, toggle }
}
export function StatusPopover() { export function StatusPopover() {
const sync = useSync() const sync = useSync()
const sdk = useSDK() const sdk = useSDK()
@@ -161,35 +26,115 @@ export function StatusPopover() {
const language = useLanguage() const language = useLanguage()
const navigate = useNavigate() const navigate = useNavigate()
const [store, setStore] = createStore({
status: {} as Record<string, ServerHealth | undefined>,
loading: null as string | null,
defaultServerUrl: undefined as string | undefined,
})
const fetcher = platform.fetch ?? globalThis.fetch const fetcher = platform.fetch ?? globalThis.fetch
const servers = createMemo(() => { const servers = createMemo(() => {
const current = server.url const current = server.url
const list = server.list const list = server.list
if (!current) return list if (!current) return list
if (!list.includes(current)) return [current, ...list] if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((item) => item !== current)] return [current, ...list.filter((x) => x !== current)]
}) })
const health = useServerHealth(servers, fetcher)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health)) const sortedServers = createMemo(() => {
const mcp = useMcpToggle({ sync, sdk, language }) const list = servers()
const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl) if (!list.length) return list
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const active = server.url
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status const order = new Map(list.map((url, index) => [url, index] as const))
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff = rank(store.status[a]) - rank(store.status[b])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
async function refreshHealth() {
const results: Record<string, ServerHealth> = {}
await Promise.all(
servers().map(async (url) => {
results[url] = await checkServerHealth(url, fetcher)
}),
)
setStore("status", reconcile(results))
}
createEffect(() => {
servers()
refreshHealth()
const interval = setInterval(refreshHealth, 10_000)
onCleanup(() => clearInterval(interval))
})
const mcpItems = createMemo(() =>
Object.entries(sync.data.mcp ?? {})
.map(([name, status]) => ({ name, status: status.status }))
.sort((a, b) => a.name.localeCompare(b.name)),
)
const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length)
const toggleMcp = async (name: string) => {
if (store.loading) return
setStore("loading", name)
try {
const status = sync.data.mcp[name]
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data)
} catch (err) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
} finally {
setStore("loading", null)
}
}
const lspItems = createMemo(() => sync.data.lsp ?? []) const lspItems = createMemo(() => sync.data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length) const lspCount = createMemo(() => lspItems().length)
const plugins = createMemo(() => sync.data.config.plugin ?? []) const plugins = createMemo(() => sync.data.config.plugin ?? [])
const pluginCount = createMemo(() => plugins().length) const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
const overallHealthy = createMemo(() => { const overallHealthy = createMemo(() => {
const serverHealthy = server.healthy() === true const serverHealthy = server.healthy() === true
const anyMcpIssue = mcpNames().some((name) => { const anyMcpIssue = mcpItems().some((m) => m.status !== "connected" && m.status !== "disabled")
const status = mcpStatus(name)
return status !== "connected" && status !== "disabled"
})
return serverHealthy && !anyMcpIssue return serverHealthy && !anyMcpIssue
}) })
const serverCount = createMemo(() => sortedServers().length)
const refreshDefaultServerUrl = () => {
const result = platform.getDefaultServerUrl?.()
if (!result) {
setStore("defaultServerUrl", undefined)
return
}
if (result instanceof Promise) {
result.then((url) => setStore("defaultServerUrl", url ? normalizeServerUrl(url) : undefined))
return
}
setStore("defaultServerUrl", normalizeServerUrl(result))
}
createEffect(() => {
refreshDefaultServerUrl()
})
return ( return (
<Popover <Popover
triggerAs={Button} triggerAs={Button}
@@ -228,7 +173,7 @@ export function StatusPopover() {
> >
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10"> <Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular"> <Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""} {serverCount() > 0 ? `${serverCount()} ` : ""}
{language.t("status.popover.tab.servers")} {language.t("status.popover.tab.servers")}
</Tabs.Trigger> </Tabs.Trigger>
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular"> <Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
@@ -250,7 +195,11 @@ export function StatusPopover() {
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14"> <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={sortedServers()}> <For each={sortedServers()}>
{(url) => { {(url) => {
const isBlocked = () => health[url]?.healthy === false const isActive = () => url === server.url
const isDefault = () => url === store.defaultServerUrl
const status = () => store.status[url]
const isBlocked = () => status()?.healthy === false
return ( return (
<button <button
type="button" type="button"
@@ -268,13 +217,13 @@ export function StatusPopover() {
> >
<ServerRow <ServerRow
url={url} url={url}
status={health[url]} status={status()}
dimmed={isBlocked()} dimmed={isBlocked()}
class="flex items-center gap-2 w-full min-w-0" class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate" nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate" versionClass="text-12-regular text-text-weak truncate"
badge={ badge={
<Show when={url === defaultServer.url()}> <Show when={isDefault()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md"> <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{language.t("common.default")} {language.t("common.default")}
</span> </span>
@@ -282,7 +231,7 @@ export function StatusPopover() {
} }
> >
<div class="flex-1" /> <div class="flex-1" />
<Show when={url === server.url}> <Show when={isActive()}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" /> <Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show> </Show>
</ServerRow> </ServerRow>
@@ -294,7 +243,7 @@ export function StatusPopover() {
<Button <Button
variant="secondary" variant="secondary"
class="mt-3 self-start h-8 px-3 py-1.5" class="mt-3 self-start h-8 px-3 py-1.5"
onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)} onClick={() => dialog.show(() => <DialogSelectServer />, refreshDefaultServerUrl)}
> >
{language.t("status.popover.action.manageServers")} {language.t("status.popover.action.manageServers")}
</Button> </Button>
@@ -306,40 +255,39 @@ export function StatusPopover() {
<div class="flex flex-col px-2 pb-2"> <div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14"> <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show <Show
when={mcpNames().length > 0} when={mcpItems().length > 0}
fallback={ fallback={
<div class="text-14-regular text-text-base text-center my-auto"> <div class="text-14-regular text-text-base text-center my-auto">
{language.t("dialog.mcp.empty")} {language.t("dialog.mcp.empty")}
</div> </div>
} }
> >
<For each={mcpNames()}> <For each={mcpItems()}>
{(name) => { {(item) => {
const status = () => mcpStatus(name) const enabled = () => item.status === "connected"
const enabled = () => status() === "connected"
return ( return (
<button <button
type="button" type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left" class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => mcp.toggle(name)} onClick={() => toggleMcp(item.name)}
disabled={mcp.loading() === name} disabled={store.loading === item.name}
> >
<div <div
classList={{ classList={{
"size-1.5 rounded-full shrink-0": true, "size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": status() === "connected", "bg-icon-success-base": item.status === "connected",
"bg-icon-critical-base": status() === "failed", "bg-icon-critical-base": item.status === "failed",
"bg-border-weak-base": status() === "disabled", "bg-border-weak-base": item.status === "disabled",
"bg-icon-warning-base": "bg-icon-warning-base":
status() === "needs_auth" || status() === "needs_client_registration", item.status === "needs_auth" || item.status === "needs_client_registration",
}} }}
/> />
<span class="text-14-regular text-text-base truncate flex-1">{name}</span> <span class="text-14-regular text-text-base truncate flex-1">{item.name}</span>
<div onClick={(event) => event.stopPropagation()}> <div onClick={(event) => event.stopPropagation()}>
<Switch <Switch
checked={enabled()} checked={enabled()}
disabled={mcp.loading() === name} disabled={store.loading === item.name}
onChange={() => mcp.toggle(name)} onChange={() => toggleMcp(item.name)}
/> />
</div> </div>
</button> </button>
@@ -386,7 +334,23 @@ export function StatusPopover() {
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14"> <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show <Show
when={plugins().length > 0} when={plugins().length > 0}
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>} fallback={
<div class="text-14-regular text-text-base text-center my-auto">
{(() => {
const value = language.t("dialog.plugins.empty")
const file = "opencode.json"
const parts = value.split(file)
if (parts.length === 1) return value
return (
<>
{parts[0]}
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
{parts.slice(1).join(file)}
</>
)
})()}
</div>
}
> >
<For each={plugins()}> <For each={plugins()}>
{(plugin) => ( {(plugin) => (
+128 -206
View File
@@ -10,7 +10,6 @@ import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@openco
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer"
const TOGGLE_TERMINAL_ID = "terminal.toggle" const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`" const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
@@ -57,91 +56,6 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
}, },
} }
const debugTerminal = (...values: unknown[]) => {
if (!import.meta.env.DEV) return
console.debug("[terminal]", ...values)
}
const useTerminalUiBindings = (input: {
container: HTMLDivElement
term: Term
cleanups: VoidFunction[]
handlePointerDown: () => void
handleLinkClick: (event: MouseEvent) => void
}) => {
const handleCopy = (event: ClipboardEvent) => {
const selection = input.term.getSelection()
if (!selection) return
const clipboard = event.clipboardData
if (!clipboard) return
event.preventDefault()
clipboard.setData("text/plain", selection)
}
const handlePaste = (event: ClipboardEvent) => {
const clipboard = event.clipboardData
const text = clipboard?.getData("text/plain") ?? clipboard?.getData("text") ?? ""
if (!text) return
event.preventDefault()
event.stopPropagation()
input.term.paste(text)
}
const handleTextareaFocus = () => {
input.term.options.cursorBlink = true
}
const handleTextareaBlur = () => {
input.term.options.cursorBlink = false
}
input.container.addEventListener("copy", handleCopy, true)
input.cleanups.push(() => input.container.removeEventListener("copy", handleCopy, true))
input.container.addEventListener("paste", handlePaste, true)
input.cleanups.push(() => input.container.removeEventListener("paste", handlePaste, true))
input.container.addEventListener("pointerdown", input.handlePointerDown)
input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown))
input.container.addEventListener("click", input.handleLinkClick, { capture: true })
input.cleanups.push(() => input.container.removeEventListener("click", input.handleLinkClick, { capture: true }))
input.term.textarea?.addEventListener("focus", handleTextareaFocus)
input.term.textarea?.addEventListener("blur", handleTextareaBlur)
input.cleanups.push(() => input.term.textarea?.removeEventListener("focus", handleTextareaFocus))
input.cleanups.push(() => input.term.textarea?.removeEventListener("blur", handleTextareaBlur))
}
const persistTerminal = (input: {
term: Term | undefined
addon: SerializeAddon | undefined
cursor: number
pty: LocalPTY
onCleanup?: (pty: LocalPTY) => void
}) => {
if (!input.addon || !input.onCleanup || !input.term) return
const buffer = (() => {
try {
return input.addon.serialize()
} catch {
debugTerminal("failed to serialize terminal buffer")
return ""
}
})()
input.onCleanup({
...input.pty,
buffer,
cursor: input.cursor,
rows: input.term.rows,
cols: input.term.cols,
scrollY: input.term.getViewportY(),
})
}
export const Terminal = (props: TerminalProps) => { export const Terminal = (props: TerminalProps) => {
const platform = usePlatform() const platform = usePlatform()
const sdk = useSDK() const sdk = useSDK()
@@ -156,16 +70,13 @@ export const Terminal = (props: TerminalProps) => {
let serializeAddon: SerializeAddon let serializeAddon: SerializeAddon
let fitAddon: FitAddon let fitAddon: FitAddon
let handleResize: () => void let handleResize: () => void
let fitFrame: number | undefined let handleTextareaFocus: () => void
let sizeTimer: ReturnType<typeof setTimeout> | undefined let handleTextareaBlur: () => void
let pendingSize: { cols: number; rows: number } | undefined
let lastSize: { cols: number; rows: number } | undefined
let disposed = false let disposed = false
const cleanups: VoidFunction[] = [] const cleanups: VoidFunction[] = []
const start = const start =
typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
let cursor = start ?? 0 let cursor = start ?? 0
let output: ReturnType<typeof terminalWriter> | undefined
const cleanup = () => { const cleanup = () => {
if (!cleanups.length) return if (!cleanups.length) return
@@ -173,23 +84,12 @@ export const Terminal = (props: TerminalProps) => {
for (const fn of fns) { for (const fn of fns) {
try { try {
fn() fn()
} catch (err) { } catch {
debugTerminal("cleanup failed", err) // ignore
} }
} }
} }
const pushSize = (cols: number, rows: number) => {
return sdk.client.pty
.update({
ptyID: local.pty.id,
size: { cols, rows },
})
.catch((err) => {
debugTerminal("failed to sync terminal size", err)
})
}
const getTerminalColors = (): TerminalColors => { const getTerminalColors = (): TerminalColors => {
const mode = theme.mode() === "dark" ? "dark" : "light" const mode = theme.mode() === "dark" ? "dark" : "light"
const fallback = DEFAULT_TERMINAL_COLORS[mode] const fallback = DEFAULT_TERMINAL_COLORS[mode]
@@ -213,43 +113,6 @@ export const Terminal = (props: TerminalProps) => {
const [terminalColors, setTerminalColors] = createSignal<TerminalColors>(getTerminalColors()) 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(() => { createEffect(() => {
const colors = getTerminalColors() const colors = getTerminalColors()
setTerminalColors(colors) setTerminalColors(colors)
@@ -261,16 +124,6 @@ export const Terminal = (props: TerminalProps) => {
const font = monoFontFamily(settings.appearance.font()) const font = monoFontFamily(settings.appearance.font())
if (!term) return if (!term) return
setOptionIfSupported(term, "fontFamily", font) 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 = () => { const focusTerminal = () => {
@@ -314,6 +167,25 @@ export const Terminal = (props: TerminalProps) => {
const once = { value: false } 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 restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize = const restoreSize =
restore && restore &&
@@ -334,7 +206,7 @@ export const Terminal = (props: TerminalProps) => {
fontSize: 14, fontSize: 14,
fontFamily: monoFontFamily(settings.appearance.font()), fontFamily: monoFontFamily(settings.appearance.font()),
allowTransparency: false, allowTransparency: false,
convertEol: false, convertEol: true,
theme: terminalColors(), theme: terminalColors(),
scrollback: 10_000, scrollback: 10_000,
ghostty: g, ghostty: g,
@@ -346,7 +218,27 @@ export const Terminal = (props: TerminalProps) => {
} }
ghostty = g ghostty = g
term = t term = t
output = terminalWriter((data) => t.write(data))
const handleCopy = (event: ClipboardEvent) => {
const selection = t.getSelection()
if (!selection) return
const clipboard = event.clipboardData
if (!clipboard) return
event.preventDefault()
clipboard.setData("text/plain", selection)
}
const handlePaste = (event: ClipboardEvent) => {
const clipboard = event.clipboardData
const text = clipboard?.getData("text/plain") ?? clipboard?.getData("text") ?? ""
if (!text) return
event.preventDefault()
event.stopPropagation()
t.paste(text)
}
t.attachCustomKeyEventHandler((event) => { t.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase() const key = event.key.toLowerCase()
@@ -363,6 +255,12 @@ export const Terminal = (props: TerminalProps) => {
return matchKeybind(keybinds, event) return matchKeybind(keybinds, event)
}) })
container.addEventListener("copy", handleCopy, true)
cleanups.push(() => container.removeEventListener("copy", handleCopy, true))
container.addEventListener("paste", handlePaste, true)
cleanups.push(() => container.removeEventListener("paste", handlePaste, true))
const fit = new mod.FitAddon() const fit = new mod.FitAddon()
const serializer = new SerializeAddon() const serializer = new SerializeAddon()
cleanups.push(() => disposeIfDisposable(fit)) cleanups.push(() => disposeIfDisposable(fit))
@@ -372,32 +270,30 @@ export const Terminal = (props: TerminalProps) => {
serializeAddon = serializer serializeAddon = serializer
t.open(container) t.open(container)
useTerminalUiBindings({ container, term: t, cleanups, handlePointerDown, handleLinkClick })
container.addEventListener("pointerdown", handlePointerDown)
cleanups.push(() => container.removeEventListener("pointerdown", handlePointerDown))
container.addEventListener("click", handleLinkClick, { capture: true })
cleanups.push(() => container.removeEventListener("click", handleLinkClick, { capture: true }))
handleTextareaFocus = () => {
t.options.cursorBlink = true
}
handleTextareaBlur = () => {
t.options.cursorBlink = false
}
t.textarea?.addEventListener("focus", handleTextareaFocus)
t.textarea?.addEventListener("blur", handleTextareaBlur)
cleanups.push(() => t.textarea?.removeEventListener("focus", handleTextareaFocus))
cleanups.push(() => t.textarea?.removeEventListener("blur", handleTextareaBlur))
focusTerminal() 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 = () => { const startResize = () => {
fit.observeResize() fit.observeResize()
handleResize = scheduleFit handleResize = () => fit.fit()
window.addEventListener("resize", handleResize) window.addEventListener("resize", handleResize)
cleanups.push(() => window.removeEventListener("resize", handleResize)) cleanups.push(() => window.removeEventListener("resize", handleResize))
} }
@@ -405,13 +301,11 @@ export const Terminal = (props: TerminalProps) => {
if (restore && restoreSize) { if (restore && restoreSize) {
t.write(restore, () => { t.write(restore, () => {
fit.fit() fit.fit()
scheduleSize(t.cols, t.rows)
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY) if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
startResize() startResize()
}) })
} else { } else {
fit.fit() fit.fit()
scheduleSize(t.cols, t.rows)
if (restore) { if (restore) {
t.write(restore, () => { t.write(restore, () => {
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY) if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
@@ -420,38 +314,51 @@ export const Terminal = (props: TerminalProps) => {
startResize() startResize()
} }
const onResize = t.onResize(async (size) => {
if (socket.readyState === WebSocket.OPEN) {
await sdk.client.pty
.update({
ptyID: local.pty.id,
size: {
cols: size.cols,
rows: size.rows,
},
})
.catch(() => {})
}
})
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) => { // t.onScroll((ydisp) => {
// console.log("Scroll position:", 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 = () => { const handleOpen = () => {
local.onConnect?.() local.onConnect?.()
scheduleSize(t.cols, t.rows) sdk.client.pty
.update({
ptyID: local.pty.id,
size: {
cols: t.cols,
rows: t.rows,
},
})
.catch(() => {})
} }
socket.addEventListener("open", handleOpen) socket.addEventListener("open", handleOpen)
cleanups.push(() => socket.removeEventListener("open", handleOpen)) cleanups.push(() => socket.removeEventListener("open", handleOpen))
if (socket.readyState === WebSocket.OPEN) handleOpen()
const decoder = new TextDecoder() const decoder = new TextDecoder()
const handleMessage = (event: MessageEvent) => { const handleMessage = (event: MessageEvent) => {
@@ -467,15 +374,15 @@ export const Terminal = (props: TerminalProps) => {
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) { if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
cursor = next cursor = next
} }
} catch (err) { } catch {
debugTerminal("invalid websocket control frame", err) // ignore
} }
return return
} }
const data = typeof event.data === "string" ? event.data : "" const data = typeof event.data === "string" ? event.data : ""
if (!data) return if (!data) return
output?.push(data) t.write(data)
cursor += data.length cursor += data.length
} }
socket.addEventListener("message", handleMessage) socket.addEventListener("message", handleMessage)
@@ -518,10 +425,25 @@ export const Terminal = (props: TerminalProps) => {
onCleanup(() => { onCleanup(() => {
disposed = true disposed = true
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame) const t = term
if (sizeTimer !== undefined) clearTimeout(sizeTimer) if (serializeAddon && props.onCleanup && t) {
output?.flush() const buffer = (() => {
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup }) try {
return serializeAddon.serialize()
} catch {
return ""
}
})()
props.onCleanup({
...local.pty,
buffer,
cursor,
rows: t.rows,
cols: t.cols,
scrollY: t.getViewportY(),
})
}
cleanup() cleanup()
}) })
@@ -535,7 +457,7 @@ export const Terminal = (props: TerminalProps) => {
classList={{ classList={{
...(local.classList ?? {}), ...(local.classList ?? {}),
"select-text": true, "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, [local.class ?? ""]: !!local.class,
}} }}
{...others} {...others}
+23 -25
View File
@@ -13,28 +13,6 @@ import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { applyPath, backPath, forwardPath } from "./titlebar-history" import { applyPath, backPath, forwardPath } from "./titlebar-history"
type TauriDesktopWindow = {
startDragging?: () => Promise<void>
toggleMaximize?: () => Promise<void>
}
type TauriThemeWindow = {
setTheme?: (theme?: "light" | "dark" | null) => Promise<void>
}
type TauriApi = {
window?: {
getCurrentWindow?: () => TauriDesktopWindow
}
webviewWindow?: {
getCurrentWebviewWindow?: () => TauriThemeWindow
}
}
const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__
const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.()
const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.()
export function Titlebar() { export function Titlebar() {
const layout = useLayout() const layout = useLayout()
const platform = usePlatform() const platform = usePlatform()
@@ -104,7 +82,22 @@ export function Titlebar() {
const getWin = () => { const getWin = () => {
if (platform.platform !== "desktop") return if (platform.platform !== "desktop") return
return currentDesktopWindow()
const tauri = (
window as unknown as {
__TAURI__?: {
window?: {
getCurrentWindow?: () => {
startDragging?: () => Promise<void>
toggleMaximize?: () => Promise<void>
}
}
}
}
).__TAURI__
if (!tauri?.window?.getCurrentWindow) return
return tauri.window.getCurrentWindow()
} }
createEffect(() => { createEffect(() => {
@@ -113,8 +106,13 @@ export function Titlebar() {
const scheme = theme.colorScheme() const scheme = theme.colorScheme()
const value = scheme === "system" ? null : scheme const value = scheme === "system" ? null : scheme
const win = currentThemeWindow() const tauri = (window as unknown as { __TAURI__?: { webviewWindow?: { getCurrentWebviewWindow?: () => unknown } } })
if (!win?.setTheme) return .__TAURI__
const get = tauri?.webviewWindow?.getCurrentWebviewWindow
if (!get) return
const win = get() as { setTheme?: (theme?: "light" | "dark" | null) => Promise<void> }
if (!win.setTheme) return
void win.setTheme(value).catch(() => undefined) void win.setTheme(value).catch(() => undefined)
}) })
+9 -36
View File
@@ -11,7 +11,6 @@ const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(na
const PALETTE_ID = "command.palette" const PALETTE_ID = "command.palette"
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p" const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
const SUGGESTED_PREFIX = "suggested." const SUGGESTED_PREFIX = "suggested."
const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new"])
function actionId(id: string) { function actionId(id: string) {
if (!id.startsWith(SUGGESTED_PREFIX)) return id if (!id.startsWith(SUGGESTED_PREFIX)) return id
@@ -34,11 +33,6 @@ function signatureFromEvent(event: KeyboardEvent) {
return signature(normalizeKey(event.key), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey) return signature(normalizeKey(event.key), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey)
} }
function isAllowedEditableKeybind(id: string | undefined) {
if (!id) return false
return EDITABLE_KEYBIND_IDS.has(actionId(id))
}
export type KeybindConfig = string export type KeybindConfig = string
export interface Keybind { export interface Keybind {
@@ -62,8 +56,6 @@ export interface CommandOption {
onHighlight?: () => (() => void) | void onHighlight?: () => (() => void) | void
} }
type CommandSource = "palette" | "keybind" | "slash"
export type CommandCatalogItem = { export type CommandCatalogItem = {
title: string title: string
description?: string description?: string
@@ -177,14 +169,6 @@ export function formatKeybind(config: string): string {
return IS_MAC ? parts.join("") : parts.join("+") return IS_MAC ? parts.join("") : parts.join("+")
} }
function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false
if (target.isContentEditable) return true
if (target.closest("[contenteditable='true']")) return true
if (target.closest("input, textarea, select")) return true
return false
}
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({ export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
name: "Command", name: "Command",
init: () => { init: () => {
@@ -291,18 +275,13 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
return map return map
}) })
const optionMap = createMemo(() => { const run = (id: string, source?: "palette" | "keybind" | "slash") => {
const map = new Map<string, CommandOption>()
for (const option of options()) { for (const option of options()) {
map.set(option.id, option) if (option.id === id || option.id === "suggested." + id) {
map.set(actionId(option.id), option) option.onSelect?.(source)
return
}
} }
return map
})
const run = (id: string, source?: CommandSource) => {
const option = optionMap().get(id)
option?.onSelect?.(source)
} }
const showPalette = () => { const showPalette = () => {
@@ -313,20 +292,14 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
if (suspended() || dialog.active) return if (suspended() || dialog.active) return
const sig = signatureFromEvent(event) const sig = signatureFromEvent(event)
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) if (palette().has(sig)) {
return
if (isPalette) {
event.preventDefault() event.preventDefault()
showPalette() showPalette()
return return
} }
const option = keymap().get(sig)
if (!option) return if (!option) return
event.preventDefault() event.preventDefault()
option.onSelect?.("keybind") option.onSelect?.("keybind")
@@ -359,7 +332,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
return { return {
register, register,
trigger(id: string, source?: CommandSource) { trigger(id: string, source?: "palette" | "keybind" | "slash") {
run(id, source) run(id, source)
}, },
keybind(id: string) { keybind(id: string) {
@@ -378,7 +351,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}, },
show: showPalette, show: showPalette,
keybinds(enabled: boolean) { keybinds(enabled: boolean) {
setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1))) setStore("suspendCount", (count) => count + (enabled ? -1 : 1))
}, },
suspended, suspended,
get catalog() { get catalog() {
-41
View File
@@ -109,45 +109,4 @@ describe("comments session indexing", () => {
dispose() dispose()
}) })
}) })
test("remove keeps focus when same comment id exists in another file", () => {
createRoot((dispose) => {
const comments = createCommentSessionForTest({
"a.ts": [line("a.ts", "shared", 10)],
"b.ts": [line("b.ts", "shared", 20)],
})
comments.setFocus({ file: "b.ts", id: "shared" })
comments.remove("a.ts", "shared")
expect(comments.focus()).toEqual({ file: "b.ts", id: "shared" })
expect(comments.list("a.ts")).toEqual([])
expect(comments.list("b.ts").map((item) => item.id)).toEqual(["shared"])
dispose()
})
})
test("setFocus and setActive updater callbacks receive current state", () => {
createRoot((dispose) => {
const comments = createCommentSessionForTest()
comments.setFocus({ file: "a.ts", id: "a1" })
comments.setFocus((current) => {
expect(current).toEqual({ file: "a.ts", id: "a1" })
return { file: "b.ts", id: "b1" }
})
comments.setActive({ file: "c.ts", id: "c1" })
comments.setActive((current) => {
expect(current).toEqual({ file: "c.ts", id: "c1" })
return null
})
expect(comments.focus()).toEqual({ file: "b.ts", id: "b1" })
expect(comments.active()).toBeNull()
dispose()
})
})
}) })
+28 -30
View File
@@ -1,4 +1,4 @@
import { batch, createMemo, createRoot, onCleanup } from "solid-js" import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
@@ -20,19 +20,6 @@ type CommentFocus = { file: string; id: string }
const WORKSPACE_KEY = "__workspace__" const WORKSPACE_KEY = "__workspace__"
const MAX_COMMENT_SESSIONS = 20 const MAX_COMMENT_SESSIONS = 20
function sessionKey(dir: string, id: string | undefined) {
return `${dir}\n${id ?? WORKSPACE_KEY}`
}
function decodeSessionKey(key: string) {
const split = key.lastIndexOf("\n")
if (split < 0) return { dir: key, id: WORKSPACE_KEY }
return {
dir: key.slice(0, split),
id: key.slice(split + 1),
}
}
type CommentStore = { type CommentStore = {
comments: Record<string, LineComment[]> comments: Record<string, LineComment[]>
} }
@@ -44,24 +31,24 @@ function aggregate(comments: Record<string, LineComment[]>) {
.sort((a, b) => a.time - b.time) .sort((a, b) => a.time - b.time)
} }
function insert(items: LineComment[], next: LineComment) {
const index = items.findIndex((item) => item.time > next.time)
if (index < 0) return [...items, next]
return [...items.slice(0, index), next, ...items.slice(index)]
}
function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) { function createCommentSessionState(store: Store<CommentStore>, setStore: SetStoreFunction<CommentStore>) {
const [state, setState] = createStore({ const [state, setState] = createStore({
focus: null as CommentFocus | null, focus: null as CommentFocus | null,
active: null as CommentFocus | null, active: null as CommentFocus | null,
all: aggregate(store.comments),
}) })
const all = () => aggregate(store.comments)
const setRef = (
key: "focus" | "active",
value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null),
) => setState(key, value)
const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) => const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
setRef("focus", value) setState("focus", value)
const setActive = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) => const setActive = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
setRef("active", value) setState("active", value)
const list = (file: string) => store.comments[file] ?? [] const list = (file: string) => store.comments[file] ?? []
@@ -74,6 +61,7 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
batch(() => { batch(() => {
setStore("comments", input.file, (items) => [...(items ?? []), next]) setStore("comments", input.file, (items) => [...(items ?? []), next])
setState("all", (items) => insert(items, next))
setFocus({ file: input.file, id: next.id }) setFocus({ file: input.file, id: next.id })
}) })
@@ -83,13 +71,15 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
const remove = (file: string, id: string) => { const remove = (file: string, id: string) => {
batch(() => { batch(() => {
setStore("comments", file, (items) => (items ?? []).filter((item) => item.id !== id)) setStore("comments", file, (items) => (items ?? []).filter((item) => item.id !== id))
setFocus((current) => (current?.file === file && current.id === id ? null : current)) setState("all", (items) => items.filter((item) => !(item.file === file && item.id === id)))
setFocus((current) => (current?.id === id ? null : current))
}) })
} }
const clear = () => { const clear = () => {
batch(() => { batch(() => {
setStore("comments", reconcile({})) setStore("comments", reconcile({}))
setState("all", [])
setFocus(null) setFocus(null)
setActive(null) setActive(null)
}) })
@@ -97,16 +87,17 @@ function createCommentSessionState(store: Store<CommentStore>, setStore: SetStor
return { return {
list, list,
all, all: () => state.all,
add, add,
remove, remove,
clear, clear,
focus: () => state.focus, focus: () => state.focus,
setFocus, setFocus,
clearFocus: () => setRef("focus", null), clearFocus: () => setFocus(null),
active: () => state.active, active: () => state.active,
setActive, setActive,
clearActive: () => setRef("active", null), clearActive: () => setActive(null),
reindex: () => setState("all", aggregate(store.comments)),
} }
} }
@@ -126,6 +117,11 @@ function createCommentSession(dir: string, id: string | undefined) {
) )
const session = createCommentSessionState(store, setStore) const session = createCommentSessionState(store, setStore)
createEffect(() => {
if (!ready()) return
session.reindex()
})
return { return {
ready, ready,
list: session.list, list: session.list,
@@ -149,9 +145,11 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
const params = useParams() const params = useParams()
const cache = createScopedCache( const cache = createScopedCache(
(key) => { (key) => {
const decoded = decodeSessionKey(key) const split = key.lastIndexOf("\n")
const dir = split >= 0 ? key.slice(0, split) : key
const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
return createRoot((dispose) => ({ return createRoot((dispose) => ({
value: createCommentSession(decoded.dir, decoded.id === WORKSPACE_KEY ? undefined : decoded.id), value: createCommentSession(dir, id === WORKSPACE_KEY ? undefined : id),
dispose, dispose,
})) }))
}, },
@@ -164,7 +162,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
onCleanup(() => cache.clear()) onCleanup(() => cache.clear())
const load = (dir: string, id: string | undefined) => { const load = (dir: string, id: string | undefined) => {
const key = sessionKey(dir, id) const key = `${dir}\n${id ?? WORKSPACE_KEY}`
return cache.get(key).value return cache.get(key).value
} }
+44 -57
View File
@@ -43,12 +43,6 @@ export {
touchFileContent, touchFileContent,
} }
function errorMessage(error: unknown) {
if (error instanceof Error && error.message) return error.message
if (typeof error === "string" && error) return error
return "Unknown error"
}
export const { use: useFile, provider: FileProvider } = createSimpleContext({ export const { use: useFile, provider: FileProvider } = createSimpleContext({
name: "File", name: "File",
gate: false, gate: false,
@@ -116,45 +110,6 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setStore("file", file, { path: file, name: getFilename(file) }) setStore("file", file, { path: file, name: getFilename(file) })
} }
const setLoading = (file: string) => {
setStore(
"file",
file,
produce((draft) => {
draft.loading = true
draft.error = undefined
}),
)
}
const setLoaded = (file: string, content: FileState["content"]) => {
setStore(
"file",
file,
produce((draft) => {
draft.loaded = true
draft.loading = false
draft.content = content
}),
)
}
const setLoadError = (file: string, message: string) => {
setStore(
"file",
file,
produce((draft) => {
draft.loading = false
draft.error = message
}),
)
showToast({
variant: "error",
title: language.t("toast.file.loadFailed.title"),
description: message,
})
}
const load = (input: string, options?: { force?: boolean }) => { const load = (input: string, options?: { force?: boolean }) => {
const file = path.normalize(input) const file = path.normalize(input)
if (!file) return Promise.resolve() if (!file) return Promise.resolve()
@@ -169,14 +124,29 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const pending = inflight.get(key) const pending = inflight.get(key)
if (pending) return pending if (pending) return pending
setLoading(file) setStore(
"file",
file,
produce((draft) => {
draft.loading = true
draft.error = undefined
}),
)
const promise = sdk.client.file const promise = sdk.client.file
.read({ path: file }) .read({ path: file })
.then((x) => { .then((x) => {
if (scope() !== directory) return if (scope() !== directory) return
const content = x.data const content = x.data
setLoaded(file, content) setStore(
"file",
file,
produce((draft) => {
draft.loaded = true
draft.loading = false
draft.content = content
}),
)
if (!content) return if (!content) return
touchFileContent(file, approxBytes(content)) touchFileContent(file, approxBytes(content))
@@ -184,7 +154,19 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
}) })
.catch((e) => { .catch((e) => {
if (scope() !== directory) return if (scope() !== directory) return
setLoadError(file, errorMessage(e)) setStore(
"file",
file,
produce((draft) => {
draft.loading = false
draft.error = e.message
}),
)
showToast({
variant: "error",
title: language.t("toast.file.loadFailed.title"),
description: e.message,
})
}) })
.finally(() => { .finally(() => {
inflight.delete(key) inflight.delete(key)
@@ -229,16 +211,21 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return state return state
} }
function withPath(input: string, action: (file: string) => unknown) { const scrollTop = (input: string) => view().scrollTop(path.normalize(input))
return action(path.normalize(input)) const scrollLeft = (input: string) => view().scrollLeft(path.normalize(input))
const selectedLines = (input: string) => view().selectedLines(path.normalize(input))
const setScrollTop = (input: string, top: number) => {
view().setScrollTop(path.normalize(input), top)
}
const setScrollLeft = (input: string, left: number) => {
view().setScrollLeft(path.normalize(input), left)
}
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
view().setSelectedLines(path.normalize(input), range)
} }
const scrollTop = (input: string) => withPath(input, (file) => view().scrollTop(file))
const scrollLeft = (input: string) => withPath(input, (file) => view().scrollLeft(file))
const selectedLines = (input: string) => withPath(input, (file) => view().selectedLines(file))
const setScrollTop = (input: string, top: number) => withPath(input, (file) => view().setScrollTop(file, top))
const setScrollLeft = (input: string, left: number) => withPath(input, (file) => view().setScrollLeft(file, left))
const setSelectedLines = (input: string, range: SelectedLineRange | null) =>
withPath(input, (file) => view().setSelectedLines(file, range))
onCleanup(() => { onCleanup(() => {
stop() stop()
+21 -31
View File
@@ -23,16 +23,6 @@ function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
} }
} }
function equalSelectedLines(a: SelectedLineRange | null | undefined, b: SelectedLineRange | null | undefined) {
if (!a && !b) return true
if (!a || !b) return false
const left = normalizeSelectedLines(a)
const right = normalizeSelectedLines(b)
return (
left.start === right.start && left.end === right.end && left.side === right.side && left.endSide === right.endSide
)
}
function createViewSession(dir: string, id: string | undefined) { function createViewSession(dir: string, id: string | undefined) {
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1` const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
@@ -75,36 +65,36 @@ function createViewSession(dir: string, id: string | undefined) {
const selectedLines = (path: string) => view.file[path]?.selectedLines const selectedLines = (path: string) => view.file[path]?.selectedLines
const setScrollTop = (path: string, top: number) => { const setScrollTop = (path: string, top: number) => {
setView( setView("file", path, (current) => {
produce((draft) => { if (current?.scrollTop === top) return current
const file = draft.file[path] ?? (draft.file[path] = {}) return {
if (file.scrollTop === top) return ...(current ?? {}),
file.scrollTop = top scrollTop: top,
}), }
) })
pruneView(path) pruneView(path)
} }
const setScrollLeft = (path: string, left: number) => { const setScrollLeft = (path: string, left: number) => {
setView( setView("file", path, (current) => {
produce((draft) => { if (current?.scrollLeft === left) return current
const file = draft.file[path] ?? (draft.file[path] = {}) return {
if (file.scrollLeft === left) return ...(current ?? {}),
file.scrollLeft = left scrollLeft: left,
}), }
) })
pruneView(path) pruneView(path)
} }
const setSelectedLines = (path: string, range: SelectedLineRange | null) => { const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
const next = range ? normalizeSelectedLines(range) : null const next = range ? normalizeSelectedLines(range) : null
setView( setView("file", path, (current) => {
produce((draft) => { if (current?.selectedLines === next) return current
const file = draft.file[path] ?? (draft.file[path] = {}) return {
if (equalSelectedLines(file.selectedLines, next)) return ...(current ?? {}),
file.selectedLines = next selectedLines: next,
}), }
) })
pruneView(path) pruneView(path)
} }

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