Compare commits

...
Author SHA1 Message Date
Aiden Cline 80c6123d2c update packages to v6 2026-02-13 14:49:33 -06:00
严浩and严浩 bc1fd0633d fix(test): move timeout config to CLI flag (#13494)
Co-authored-by: 严浩 <h_mini2024@oo1.dev>
2026-02-13 10:20:02 -05:00
G36maid 88e2eb5416 docs: add pacman installation option for Arch Linux alongside AUR (#13293) 2026-02-13 09:07:48 -06:00
Jun b8848cfae1 docs(ko): polish Korean phrasing in acp, agents, config, and custom-tools docs (#13446) 2026-02-13 09:05:29 -06:00
Adam 4f51c0912d chore: cleanup 2026-02-13 05:52:43 -06:00
Adam 1c71604e0a fix(app): terminal resize 2026-02-13 05:52:42 -06:00
eytans e242fe19e4 fix(web): use prompt_async endpoint to avoid timeout over VPN/tunnel (#12749) 2026-02-13 05:25:47 -06:00
opencode-agent[bot] f991a6c0b6 chore: generate 2026-02-13 11:19:37 +00:00
Annopick b1764b2ffd docs: Fix zh-cn translation mistake in tools.mdx (#13407) 2026-02-13 05:18:47 -06:00
Chris Yang ebe5a2b74a fix(app): remount SDK/sync tree when server URL changes (#13437) 2026-02-13 05:16:14 -06:00
Jun 9f20e0d14b fix(web): sync docs locale cookie on alias redirects (#13109) 2026-02-13 05:12:28 -06:00
Filip ebb907d646 fix(desktop): performance optimization for showing large diff & files (#13460) 2026-02-13 05:08:13 -06:00
opencode-agent[bot] b8ee882126 chore: update nix node_modules hashes 2026-02-13 07:06:28 +00:00
Rahul Mishra 693127d382 feat(cli): add --dir option to run command (#12443) 2026-02-13 00:59:37 -06:00
Aiden Cline 0d90a22f90 feat: update some ai sdk packages and uuse adaptive reasoning for opus 4.6 on vertex/bedrock/anthropic (#13439) 2026-02-13 00:56:11 -06:00
opencode 34ebe814dd release: v1.1.65 2026-02-13 05:51:04 +00:00
Aiden Cline 1fb6c0b5b3 Revert "fix: token substitution in OPENCODE_CONFIG_CONTENT" (#13429) 2026-02-12 23:24:31 -06:00
Aiden Cline 98aeb60a7f fix: ensure @-ing a dir uses the read tool instead of dead list tool (#13428) 2026-02-12 23:20:33 -06:00
Spoon 1608565c80 feat(hook): add tool.definition hook for plugins to modify tool description and parameters (#4956) 2026-02-12 22:52:17 -06:00
Brendan Allan b06afd657d ci: remove signpath policy 2026-02-13 10:46:45 +08:00
Adam dd296f7033 fix(app): reconnect event stream on disconnect 2026-02-12 20:20:24 -06:00
Adam fb7b2f6b4d feat(app): toggle all provider models 2026-02-12 20:19:26 -06:00
Brendan Allan e0f1c3c20e cleanup desktop loading page 2026-02-13 10:15:36 +08:00
Adam dec304a273 fix(app): emoji as avatar 2026-02-12 20:05:58 -06:00
Adam c9719dff72 fix(app): notification should navigate to session 2026-02-12 20:04:36 -06:00
Adam 7f95cc64c5 fix(app): prompt input quirks 2026-02-12 19:58:57 -06:00
Adam b525c03d20 chore: cleanup 2026-02-12 19:52:20 -06:00
Adam 8da5fd0a66 fix(app): worktree delete 2026-02-12 19:38:13 -06:00
Adam 0303c29e3f fix(app): failed to create store 2026-02-12 19:38:06 -06:00
Brendan Allan adb0c4d4f9 desktop: only show loading window if sqlite migration is necessary 2026-02-13 08:49:52 +08:00
991496a753 fix: resolve ACP hanging indefinitely in thinking state on Windows (#13222)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-02-12 18:20:00 -06:00
opencode 76db218674 release: v1.1.64 2026-02-12 23:18:40 +00:00
Ariane Emory 29671c1397 fix: token substitution in OPENCODE_CONFIG_CONTENT (#13384) 2026-02-12 16:59:44 -06:00
Aiden Cline f66624fe6e chore: cleanup flag code (#13389) 2026-02-12 22:38:51 +00:00
opencode-agent[bot] d475fd6137 chore: generate 2026-02-12 22:14:45 +00:00
Smit Chaudhary 93eee0daf4 fix: look for recent model in fallback in cli (#12582) 2026-02-12 16:13:48 -06:00
opencode-agent[bot] 445e0d7676 chore: update nix node_modules hashes 2026-02-12 22:04:31 +00:00
Luke Parker 4018c863e3 fix: baseline CPU detection (#13371) 2026-02-13 07:50:43 +10:00
Luke Parker a8f2884521 feat: windows selection behavior, manual ctrl+c (#13315) 2026-02-13 07:38:27 +10:00
Sebastian c0814da785 do not open console on error (#13374) 2026-02-12 21:29:58 +00:00
opencode-agent[bot] 20dcff1e2e chore: generate 2026-02-12 21:23:32 +00:00
Aman Kalraandamankalra172 11dd281c92 docs: update STACKIT provider documentation with typo fix (#13357)
Co-authored-by: amankalra172 <aman.kalra@st.ovgu.de>
2026-02-12 15:22:35 -06:00
Adam 548608b7ad fix(app): terminal pty isolation 2026-02-12 15:15:34 -06:00
Adam 4e0f509e7b feat(app): option to turn off sound effects 2026-02-12 15:03:05 -06:00
Adam ff3b174c42 fix(app): normalize oauth error messages 2026-02-12 14:58:25 -06:00
Adam 70303d0b42 chore: cleanup 2026-02-12 14:48:09 -06:00
Adam 7ccf223c84 chore: cleanup 2026-02-12 14:43:20 -06:00
Adam e9b9a62fe4 chore: cleanup 2026-02-12 14:39:02 -06:00
Adam 81c623f26e chore: cleanup 2026-02-12 14:32:31 -06:00
Adam 3696d1ded1 chore: cleanup 2026-02-12 14:24:19 -06:00
Adam 50f208d69f fix(app): suggestion active state broken 2026-02-12 14:17:05 -06:00
Adam 958320f9c1 fix(app): remote http server connections 2026-02-12 13:41:22 -06:00
Aiden Cline d1ee4c8dca test: add more test cases for project.test.ts (#13355) 2026-02-12 18:46:44 +00:00
opencode ac018e3a35 release: v1.1.63 2026-02-12 18:46:38 +00:00
Dax Raad e6e9c15d34 improve codex model list 2026-02-12 18:15:30 +00:00
opencode aaee5fb680 release: v1.1.62 2026-02-12 18:15:24 +00:00
Adam ff0abacf4b fix(app): project icons unloading 2026-02-12 11:50:17 -06:00
Rasheed 0771e3a8be fix(app): preserve undo history for plain-text paste (#13351) 2026-02-12 17:27:53 +00:00
Adam da952135ca chore(app): refactor for better solidjs hygiene (#13344) 2026-02-12 11:26:19 -06:00
Dax Raad 789705ea96 ignore: document test fixtures for agents 2026-02-12 12:10:21 -05:00
Ryan Vogel ba54cee55e feat(tool): return image attachments from webfetch (#13331) 2026-02-12 12:09:29 -05:00
opencode-agent[bot] 847e06f9e1 chore: update nix node_modules hashes 2026-02-12 17:09:09 +00:00
Aiden Cline 2db618dea3 fix: downgrade bun to 1.3.5 (#13347) 2026-02-12 16:59:08 +00:00
Dylan Fiedler ecab692ca1 fix(docs): correct format attribute in StructuredOutputs (#13340) 2026-02-12 10:55:43 -06:00
Frank 59a323e9a8 wip: zen 2026-02-12 11:11:25 -05:00
Frank 658bf6fa58 zen: minimax m2.5 2026-02-12 11:04:09 -05:00
Adam a82ca86008 fix(app): more defensive code component 2026-02-12 10:00:58 -06:00
Adam ed472d8a67 fix(app): more defensive session context metrics 2026-02-12 10:00:58 -06:00
Adamopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>Frank
ff4414bb15 chore: refactor packages/app files (#13236)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Frank <frank@anoma.ly>
2026-02-12 09:49:14 -06:00
Dax Raad 56ad2db020 core: expose tool arguments in shell hook for plugin visibility 2026-02-12 09:54:47 -05:00
Frank ae811ad8d2 wip: zen 2026-02-12 14:45:52 +00:00
opencode-agent[bot] 85df106713 chore: generate 2026-02-12 14:45:52 +00:00
opencode 892bb75265 release: v1.1.61 2026-02-12 14:45:45 +00:00
Dax Raad a115565054 core: allow model configurations without npm/api provider details
Makes npm and api fields optional in the provider schema so model definitions

can be more flexible when provider package details aren't needed.
2026-02-12 09:26:28 -05:00
Frank d82d22b2d7 wip: zen 2026-02-12 09:17:49 -05:00
Ryan Vogel d723147083 feat: update to not post comment on workflows when no duplicates found (#13238) 2026-02-12 09:13:38 -05:00
opencode-agent[bot] 9f9f0fb8eb chore: update nix node_modules hashes 2026-02-12 13:36:53 +00:00
Adam ecb274273a wip(ui): diff virtualization (#12693) 2026-02-12 07:25:58 -06:00
Adam 5f421883a8 chore: style loading screen 2026-02-12 07:16:30 -06:00
Brendan Allan fa97475ee8 ci: move test-sigining policy 2026-02-12 18:50:00 +08:00
Brendan Allan 0eaeb4588e Testing SignPath Integration (#13308) 2026-02-12 18:46:56 +08:00
Brendan Allan 1413d77b1f desktop: sqlite migration progress bar (#13294) 2026-02-12 09:44:06 +00:00
Aiden Cline 624dd94b5d tweak: tool outputs to be more llm friendly (#13269) 2026-02-12 00:33:18 -06:00
Frank d86f24b6b3 zen: return cost 2026-02-12 01:08:02 -05:00
opencode 03de51bd3c release: v1.1.60 2026-02-12 05:58:24 +00:00
Luke Parker 8f9742d988 fix(win32): use ffi to get around bun raw input/ctrl+c issues (#13052) 2026-02-12 15:39:31 +10:00
opencode-agent[bot] f6e7aefa72 chore: generate 2026-02-12 04:55:00 +00:00
e269788a8f feat: support claude agent SDK-style structured outputs in the OpenCode SDK (#8161)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Dax Raad <d@ironbay.co>
2026-02-12 04:54:05 +00:00
opencode-agent[bot] 66780195dc chore: generate 2026-02-12 04:11:57 +00:00
Dax Raad ad2087094d support custom api url per model 2026-02-11 23:10:47 -05:00
github-actions[bot] 5bdf1c4b96 Update VOUCHED list
https://github.com/anomalyco/opencode/issues/13076#issuecomment-3888412535
2026-02-12 03:05:00 +00:00
Kit Langton 135f8ffb2a feat(tui): add toggle to hide session header (#13244) 2026-02-12 02:21:42 +00:00
dpuyosa bf5a01edd9 feat(opencode): Venice Add automatic variant generation for Venice models (#12106) 2026-02-11 20:21:29 -06:00
AdamandSelim 81ca2df6ad fix(app): guard randomUUID in insecure browser contexts (#13237)
Co-authored-by: Selim <31136147+selimerunkut@users.noreply.github.com>
2026-02-12 01:05:15 +00:00
Adam aea68c386a fix(docs): locale translations for nav elements and headings 2026-02-11 18:51:50 -06:00
Adam 8eea53a41e docs(ar): second-pass localization cleanup 2026-02-11 18:51:50 -06:00
Aiden Cline 3befd0c6c5 tweak: use promise all for mcp listTools calls (#13229) 2026-02-12 00:19:53 +00:00
opencode-agent[bot] 8577eb8ec9 chore: update nix node_modules hashes 2026-02-11 23:39:24 +00:00
Luke Parker c856f875a1 chore: upgrade bun to 1.3.9 (#13223) 2026-02-12 09:27:58 +10:00
opencode-agent[bot] 264dd213f9 chore: update nix node_modules hashes 2026-02-11 23:22:01 +00:00
Sebastian 125727d09c upgrade opentui to 0.1.79 (#13036) 2026-02-12 00:13:11 +01:00
Aiden Cline 8c7b35ad05 tweak: compaction check (#13214) 2026-02-11 21:43:04 +00:00
github-actions[bot] e2a33f75e1 Update VOUCHED list
https://github.com/anomalyco/opencode/issues/13204#issuecomment-3887062726
2026-02-11 20:40:39 +00:00
Aiden Cline 006d673ed2 tweak: make read tool offset 1 indexed instead of 0 to avoid confusion that could be caused by line #s being 1 based (#13198) 2026-02-11 14:02:30 -06:00
Aiden Cline 6b4d617df0 feat: adjust read tool so that it can handle dirs too (#13090) 2026-02-11 13:23:00 -06:00
Dax Raad e3471526f4 add square logo variants to brand page 2026-02-11 13:47:54 -05:00
Adam 6b30e0b752 chore: update docs sync workflow 2026-02-11 11:47:32 -06:00
Adam fbabce1125 fix(app): translations 2026-02-11 11:03:35 -06:00
opencode-agent[bot] 8f56ed5b85 chore: generate 2026-02-11 17:00:20 +00:00
Filipandopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> 81b5a6a08b fix(app):workspace reset (#13170)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-02-11 10:59:09 -06:00
opencode-agent[bot] 94cb6390aa chore: generate 2026-02-11 15:37:06 +00:00
opencode 42bea5d297 release: v1.1.59 2026-02-11 15:37:00 +00:00
Adam f252e3234c fix(app): translations 2026-02-11 09:19:16 -06:00
FilipandAdam eef3ae3e1f Fix/reverception (#13166)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2026-02-11 09:18:44 -06:00
Adam fc88dde63f test(app): more e2e tests (#13162) 2026-02-11 15:12:28 +00:00
Adam 4619e9d183 fix(app): sidebar remount 2026-02-11 15:12:28 +00:00
opencode 4dc363f306 release: v1.1.58 2026-02-11 15:12:01 +00:00
Adam 2e8082dd21 Revert "feat(desktop): add WSL backend mode (#12914)"
This reverts commit 213a87234d.
2026-02-11 08:51:41 -06:00
Adam a52fe28246 fix(app): notifications on child sessions 2026-02-11 08:47:27 -06:00
Adam 8c5ba8aeb0 fix(app): terminal PTY buffer carryover 2026-02-11 08:47:26 -06:00
Adam 50330820c0 fix(console): translations 2026-02-11 08:47:26 -06:00
Adam 7222fc0ba0 fix(app): terminal resize 2026-02-11 08:47:26 -06:00
Kit Langton 17bdb5d56a fix(tui): dismiss dialogs with ctrl+c (#12884) 2026-02-11 09:03:59 -05:00
Kit Langton 7a463cd193 fix(tui): keep /share available to copy existing link (#12532) 2026-02-11 08:58:24 -05:00
Kit Langton 352a54c698 feat(prompt): mode-specific input placeholders (#12388) 2026-02-11 08:57:59 -05:00
Kit Langton 93957da2c9 fix(tui): prevent home wordmark corruption in height-constrained terminals (#13069) 2026-02-11 08:52:14 -05:00
opencode edcfd562af release: v1.1.57 2026-02-11 13:45:34 +00:00
Adam ef5ec5dc28 fix(app): terminal copy/paste 2026-02-11 07:19:33 -06:00
Adam c426cb0f14 fix(app): copy path button styles 2026-02-11 07:19:33 -06:00
Shintaro Jokagi dd1862cc2b fix(web): prevent language select label truncation (#13100) 2026-02-11 06:43:48 -06:00
Brendan Allan a25b2af05a desktop: use tracing for logging (#13135) 2026-02-11 11:36:27 +00:00
Jack 8bfd6fdba2 fix: encode non-ASCII directory paths in v1 SDK HTTP headers (#13131) 2026-02-11 05:33:25 -06:00
Filip cf7a1b8d80 feat(desktop): enhance Windows app resolution and UI loading states (#13084) 2026-02-11 04:40:52 -06:00
opencode-agent[bot] 5ba4c0e024 chore: generate 2026-02-11 10:38:52 +00:00
Jun 567e094e6c docs(ko): improve translations for intro, cli, and commands (#13094) 2026-02-11 04:37:52 -06:00
webwww123 b523998329 fix(docs): avoid footer language selector truncation (#13124) 2026-02-11 04:33:50 -06:00
AdamandBrendan Allan 7e1247c420 fix(desktop): server spawn resilience (#13028)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-02-11 09:44:26 +00:00
Brendan Allan 783888131e fix(desktop): read wayland preference from store (#13081) 2026-02-11 08:10:24 +00:00
Brendan Allan 213a87234d feat(desktop): add WSL backend mode (#12914) 2026-02-11 07:49:48 +00:00
Aiden Cline d98bd4bd52 fix: add additional context overflow cases, remove overcorrecting ones (#13077) 2026-02-10 23:30:29 -06:00
Frank 22125d1347 wip: zen 2026-02-11 00:05:04 -05:00
Ariane Emory 8c120f2fab docs: remove 'Migrating to 1.0' documentation section (#13076) 2026-02-10 22:52:02 -06:00
opencode-agent[bot] c6ec2f47ef chore: generate 2026-02-11 01:56:08 +00:00
Aiden ClineandJames Lal 0fd6f365be fix(core): ensure compaction is more reliable, add reserve token buffer to ensure that input window has enough room to compact (#12924)
Co-authored-by: James Lal <james@littlebearlabs.io>
2026-02-10 19:55:22 -06:00
Aiden Cline 60bdb6e9ba tweak: /review prompt to look for behavior changes more explicitly (#13049) 2026-02-10 19:44:42 -06:00
Dax 6e9cd576ea fix(tui): default session sidebar to auto (#13046) 2026-02-11 01:26:01 +00:00
OpeOginni 53ec15a56a fix(tui): improve amazon-bedrock check to include container credentials (#13037) 2026-02-10 18:51:49 -06:00
github-actions[bot] a90b62267f Update VOUCHED list
https://github.com/anomalyco/opencode/issues/12841#issuecomment-3881500535
2026-02-11 00:37:36 +00:00
Frank 24556331c8 wip: zen 2026-02-10 17:56:10 -05:00
Frank 39145b99e8 wip: zen 2026-02-10 17:44:00 -05:00
Frank 0afa6e03a8 wip: zen 2026-02-10 17:36:32 -05:00
Frank 7a3c775dc1 wip: zen 2026-02-10 17:24:03 -05:00
Frank 3ea58bb790 wip: zen 2026-02-10 17:15:01 -05:00
Adam 50c705cd2d fix(docs): locale translations 2026-02-10 22:12:32 +00:00
Frank 3894c217cc wip: zen 2026-02-10 22:12:32 +00:00
Adam 66c2bb8f37 chore: update website stats 2026-02-10 22:12:32 +00:00
opencode 1bbbd51d48 release: v1.1.56 2026-02-10 22:12:25 +00:00
Adam 50f3e74d05 fix(app): task tool rendering 2026-02-10 15:28:46 -06:00
Adam 21475a1dfd fix(docs): invalid markdown 2026-02-10 15:18:57 -06:00
Filip dce4c05fa9 fix(desktop): open apps with executables on Windows (#13022) 2026-02-10 15:10:58 -06:00
Frank 8c56571ef9 zen: log error 2026-02-10 15:55:33 -05:00
Adam 92a77b72fb fix(app): don't close sidebar on session change (#13013) 2026-02-10 14:45:52 -06:00
873 changed files with 34599 additions and 25637 deletions
+3
View File
@@ -8,6 +8,8 @@
# - Denounce with minus prefix: -username or -platform:username.
# - Optional details after a space following the handle.
adamdotdevin
ariane-emory
-florianleibert
fwang
iamdavidhill
jayair
@@ -15,4 +17,5 @@ kitlangton
kommander
r44vc0rp
rekram1-node
-spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr
+7 -4
View File
@@ -64,10 +64,13 @@ jobs:
Requirements:
1. Update all relevant locale docs under packages/web/src/content/docs/<locale>/ so they reflect these English page changes.
2. Preserve frontmatter keys, internal links, code blocks, and existing locale-specific metadata unless the English change requires an update.
3. Keep locale docs structure aligned with their corresponding English pages.
4. Do not modify English source docs in packages/web/src/content/docs/*.mdx.
5. If no locale updates are needed, make no changes.
2. You MUST use the Task tool for translation work and launch subagents with subagent_type `translator` (defined in .opencode/agent/translator.md).
3. Do not translate directly in the primary agent. Use translator subagent output as the source for locale text updates.
4. Run translator subagent Task calls in parallel whenever file/locale translation work is independent.
5. Preserve frontmatter keys, internal links, code blocks, and existing locale-specific metadata unless the English change requires an update.
6. Keep locale docs structure aligned with their corresponding English pages.
7. Do not modify English source docs in packages/web/src/content/docs/*.mdx.
8. If no locale updates are needed, make no changes.
- name: Commit and push locale docs updates
if: steps.changes.outputs.has_changes == 'true'
+3 -1
View File
@@ -60,9 +60,11 @@ jobs:
run: |
COMMENT=$(bun script/duplicate-pr.ts -f pr_info.txt "Check the attached file for PR details and search for duplicates")
gh pr comment "$PR_NUMBER" --body "_The following comment was made by an LLM, it may be inaccurate:_
if [ "$COMMENT" != "No duplicate PRs found" ]; then
gh pr comment "$PR_NUMBER" --body "_The following comment was made by an LLM, it may be inaccurate:_
$COMMENT"
fi
add-contributor-label:
runs-on: ubuntu-latest
+54
View File
@@ -0,0 +1,54 @@
name: sign-cli
on:
push:
branches:
- brendan/desktop-signpath
workflow_dispatch:
permissions:
contents: read
actions: read
jobs:
sign-cli:
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@v3
with:
fetch-tags: true
- uses: ./.github/actions/setup-bun
- name: Build
run: |
./packages/opencode/script/build.ts
- name: Upload unsigned Windows CLI
id: upload_unsigned_windows_cli
uses: actions/upload-artifact@v4
with:
name: unsigned-opencode-windows-cli
path: packages/opencode/dist/opencode-windows-x64/bin/opencode.exe
if-no-files-found: error
- name: Submit SignPath signing request
id: submit_signpath_signing_request
uses: signpath/github-action-submit-signing-request@v1
with:
api-token: ${{ secrets.SIGNPATH_API_KEY }}
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }}
artifact-configuration-slug: ${{ secrets.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
github-artifact-id: ${{ steps.upload_unsigned_windows_cli.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: signed-opencode-cli
- name: Upload signed Windows CLI
uses: actions/upload-artifact@v4
with:
name: signed-opencode-windows-cli
path: signed-opencode-cli/*.exe
if-no-files-found: error
+3
View File
@@ -32,6 +32,9 @@ description: Use this when you are working on file operations like reading, writ
- Decode tool stderr with `Bun.readableStreamToText`.
- For large writes, use `Bun.write(Bun.file(path), text)`.
NOTE: Bun.file(...).exists() will return `false` if the value is a directory.
Use Filesystem.exists(...) instead if path can be file or directory
## Quick checklist
- Use Bun APIs first.
+2 -1
View File
@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS و Linux (موصى به، دائما محدث)
brew install opencode # macOS و Linux (صيغة brew الرسمية، تحديث اقل)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # اي نظام
nix run nixpkgs#opencode # او github:anomalyco/opencode لاحدث فرع dev
```
+2 -1
View File
@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS e Linux (recomendado, sempre atualizado)
brew install opencode # macOS e Linux (fórmula oficial do brew, atualiza menos)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # qualquer sistema
nix run nixpkgs#opencode # ou github:anomalyco/opencode para a branch dev mais recente
```
+2 -1
View File
@@ -51,7 +51,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS i Linux (preporučeno, uvijek ažurno)
brew install opencode # macOS i Linux (zvanična brew formula, rjeđe se ažurira)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # Bilo koji OS
nix run nixpkgs#opencode # ili github:anomalyco/opencode za najnoviji dev branch
```
+2 -1
View File
@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS og Linux (anbefalet, altid up to date)
brew install opencode # macOS og Linux (officiel brew formula, opdateres sjældnere)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # alle OS
nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch
```
+2 -1
View File
@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS und Linux (empfohlen, immer aktuell)
brew install opencode # macOS und Linux (offizielle Brew-Formula, seltener aktualisiert)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # jedes Betriebssystem
nix run nixpkgs#opencode # oder github:anomalyco/opencode für den neuesten dev-Branch
```
+2 -1
View File
@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS y Linux (recomendado, siempre al día)
brew install opencode # macOS y Linux (fórmula oficial de brew, se actualiza menos)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # cualquier sistema
nix run nixpkgs#opencode # o github:anomalyco/opencode para la rama dev más reciente
```
+2 -1
View File
@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS et Linux (recommandé, toujours à jour)
brew install opencode # macOS et Linux (formule officielle brew, mise à jour moins fréquente)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # n'importe quel OS
nix run nixpkgs#opencode # ou github:anomalyco/opencode pour la branche dev la plus récente
```
+2 -1
View File
@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS e Linux (consigliato, sempre aggiornato)
brew install opencode # macOS e Linux (formula brew ufficiale, aggiornata meno spesso)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # Qualsiasi OS
nix run nixpkgs#opencode # oppure github:anomalyco/opencode per lultima branch di sviluppo
```
+2 -1
View File
@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS と Linux(推奨。常に最新)
brew install opencode # macOS と Linux(公式 brew formula。更新頻度は低め)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # どのOSでも
nix run nixpkgs#opencode # または github:anomalyco/opencode で最新 dev ブランチ
```
+2 -1
View File
@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 및 Linux (권장, 항상 최신)
brew install opencode # macOS 및 Linux (공식 brew formula, 업데이트 빈도 낮음)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # 어떤 OS든
nix run nixpkgs#opencode # 또는 github:anomalyco/opencode 로 최신 dev 브랜치
```
+2 -1
View File
@@ -51,7 +51,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
brew install opencode # macOS and Linux (official brew formula, updated less)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # Any OS
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
```
+2 -1
View File
@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS og Linux (anbefalt, alltid oppdatert)
brew install opencode # macOS og Linux (offisiell brew-formel, oppdateres sjeldnere)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # alle OS
nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste dev-branch
```
+2 -1
View File
@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS i Linux (polecane, zawsze aktualne)
brew install opencode # macOS i Linux (oficjalna formuła brew, rzadziej aktualizowana)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # dowolny system
nix run nixpkgs#opencode # lub github:anomalyco/opencode dla najnowszej gałęzi dev
```
+2 -1
View File
@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS и Linux (рекомендуем, всегда актуально)
brew install opencode # macOS и Linux (официальная формула brew, обновляется реже)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # любая ОС
nix run nixpkgs#opencode # или github:anomalyco/opencode для самой свежей ветки dev
```
+2 -1
View File
@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS และ Linux (แนะนำ อัปเดตเสมอ)
brew install opencode # macOS และ Linux (brew formula อย่างเป็นทางการ อัปเดตน้อยกว่า)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # ระบบปฏิบัติการใดก็ได้
nix run nixpkgs#opencode # หรือ github:anomalyco/opencode สำหรับสาขาพัฒนาล่าสุด
```
+2 -1
View File
@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS ve Linux (önerilir, her zaman güncel)
brew install opencode # macOS ve Linux (resmi brew formülü, daha az güncellenir)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # Tüm işletim sistemleri
nix run nixpkgs#opencode # veya en güncel geliştirme dalı için github:anomalyco/opencode
```
+2 -1
View File
@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 和 Linux(推荐,始终保持最新)
brew install opencode # macOS 和 Linux(官方 brew formula,更新频率较低)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # 任意系统
nix run nixpkgs#opencode # 或用 github:anomalyco/opencode 获取最新 dev 分支
```
+2 -1
View File
@@ -50,7 +50,8 @@ scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 與 Linux(推薦,始終保持最新)
brew install opencode # macOS 與 Linux(官方 brew formula,更新頻率較低)
paru -S opencode-bin # Arch Linux
sudo pacman -S opencode # Arch Linux (Stable)
paru -S opencode-bin # Arch Linux (Latest from AUR)
mise use -g opencode # 任何作業系統
nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支
```
+96 -126
View File
@@ -23,7 +23,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.55",
"version": "1.1.65",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -73,7 +73,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.55",
"version": "1.1.65",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -107,7 +107,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.1.55",
"version": "1.1.65",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -134,7 +134,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.1.55",
"version": "1.1.65",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -158,7 +158,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.1.55",
"version": "1.1.65",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -182,7 +182,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.1.55",
"version": "1.1.65",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -215,7 +215,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.55",
"version": "1.1.65",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -244,7 +244,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.55",
"version": "1.1.65",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -260,7 +260,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.55",
"version": "1.1.65",
"bin": {
"opencode": "./bin/opencode",
},
@@ -268,25 +268,25 @@
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.14.1",
"@ai-sdk/amazon-bedrock": "3.0.74",
"@ai-sdk/anthropic": "2.0.58",
"@ai-sdk/azure": "2.0.91",
"@ai-sdk/cerebras": "1.0.36",
"@ai-sdk/cohere": "2.0.22",
"@ai-sdk/deepinfra": "1.0.33",
"@ai-sdk/gateway": "2.0.30",
"@ai-sdk/google": "2.0.52",
"@ai-sdk/google-vertex": "3.0.98",
"@ai-sdk/groq": "2.0.34",
"@ai-sdk/mistral": "2.0.27",
"@ai-sdk/openai": "2.0.89",
"@ai-sdk/openai-compatible": "1.0.32",
"@ai-sdk/perplexity": "2.0.23",
"@ai-sdk/provider": "2.0.1",
"@ai-sdk/provider-utils": "3.0.20",
"@ai-sdk/togetherai": "1.0.34",
"@ai-sdk/vercel": "1.0.33",
"@ai-sdk/xai": "2.0.51",
"@ai-sdk/amazon-bedrock": "4.0.59",
"@ai-sdk/anthropic": "3.0.43",
"@ai-sdk/azure": "3.0.29",
"@ai-sdk/cerebras": "2.0.33",
"@ai-sdk/cohere": "3.0.21",
"@ai-sdk/deepinfra": "2.0.34",
"@ai-sdk/gateway": "3.0.46",
"@ai-sdk/google": "3.0.29",
"@ai-sdk/google-vertex": "4.0.57",
"@ai-sdk/groq": "3.0.24",
"@ai-sdk/mistral": "3.0.20",
"@ai-sdk/openai": "3.0.28",
"@ai-sdk/openai-compatible": "2.0.30",
"@ai-sdk/perplexity": "3.0.19",
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.15",
"@ai-sdk/togetherai": "2.0.33",
"@ai-sdk/vercel": "2.0.32",
"@ai-sdk/xai": "3.0.56",
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.5.0",
"@gitlab/opencode-gitlab-auth": "1.3.2",
@@ -300,9 +300,9 @@
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.77",
"@opentui/solid": "0.1.77",
"@openrouter/ai-sdk-provider": "2.2.3",
"@opentui/core": "0.1.79",
"@opentui/solid": "0.1.79",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -310,7 +310,7 @@
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"ai-gateway-provider": "2.3.1",
"ai-gateway-provider": "3.1.1",
"bonjour-service": "1.3.0",
"bun-pty": "0.4.8",
"chokidar": "4.0.3",
@@ -366,7 +366,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.55",
"version": "1.1.65",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -386,7 +386,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.55",
"version": "1.1.65",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -397,7 +397,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.55",
"version": "1.1.65",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -410,7 +410,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.55",
"version": "1.1.65",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -452,7 +452,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.55",
"version": "1.1.65",
"dependencies": {
"zod": "catalog:",
},
@@ -463,7 +463,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.55",
"version": "1.1.65",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -513,7 +513,7 @@
"@kobalte/core": "0.13.11",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.0.2",
"@pierre/diffs": "1.1.0-beta.13",
"@playwright/test": "1.51.0",
"@solid-primitives/storage": "4.3.3",
"@solidjs/meta": "0.29.4",
@@ -522,12 +522,12 @@
"@tailwindcss/vite": "4.1.11",
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.8",
"@types/bun": "1.3.9",
"@types/luxon": "3.7.1",
"@types/node": "22.13.9",
"@types/semver": "7.7.1",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"ai": "5.0.124",
"ai": "6.0.86",
"diff": "8.0.2",
"dompurify": "3.3.1",
"fuzzysort": "3.1.0",
@@ -565,51 +565,51 @@
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.14.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.74", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-q83HE3FBb/HPIvjXsehrHOgCuGHPorSMFt6BYnzIYZy8gNnSqV1OWX4oXVsCAuYPPMtYW/KMK35hmoIFV8QKoQ=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.59", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.43", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-reIzGwpstdses389Uh8X+MRI7MjpemJmNGynyrKJF2+ib214fAMdlcNwuwITdzt0t6FhJBeMW7g2WbEjLnL5XA=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w=="],
"@ai-sdk/azure": ["@ai-sdk/azure@2.0.91", "", { "dependencies": { "@ai-sdk/openai": "2.0.89", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9tznVSs6LGQNKKxb8pKd7CkBV9yk+a/ENpFicHCj2CmBUKefxzwJ9JbUqrlK3VF6dGZw3LXq0dWxt7/Yekaj1w=="],
"@ai-sdk/azure": ["@ai-sdk/azure@3.0.29", "", { "dependencies": { "@ai-sdk/openai": "3.0.28", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z+nhcjBpu7Rmgfh+fmF9vpANVEwu7loNWlCvDo8fxrDpL44caOIKLjqKerEcgbBj3+NLfgZot1FOzvdjgEpvbw=="],
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.36", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zoJYL33+ieyd86FSP0Whm86D79d1lKPR7wUzh1SZ1oTxwYmsGyvIrmMf2Ll0JA9Ds2Es6qik4VaFCrjwGYRTIQ=="],
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@2.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.30", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-qT7H/q45vD/XezA78X6ZWwmHiSH50V0AdLcHMyfgrThFUDkjgPgNuGADnZOm/sopMuRASV2eUzL60F1zTuH1ew=="],
"@ai-sdk/cohere": ["@ai-sdk/cohere@2.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yJ9kP5cEDJwo8qpITq5TQFD8YNfNtW+HbyvWwrKMbFzmiMvIZuk95HIaFXE7PCTuZsqMA05yYu+qX/vQ3rNKjA=="],
"@ai-sdk/cohere": ["@ai-sdk/cohere@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HDUvNX2UhGpX0VyeAdOStFy07ZvjFtL+hXT5cXcwK/TBNiIhPFovTYHKVNSdNAxbH2eO7zRzygjMVUEX1X0Btw=="],
"@ai-sdk/deepgram": ["@ai-sdk/deepgram@1.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-lqmINr+1Jy2yGXxnQB6IrC2xMtUY5uK96pyKfqTj1kLlXGatKnJfXF7WTkOGgQrFqIYqpjDz+sPVR3n0KUEUtA=="],
"@ai-sdk/deepgram": ["@ai-sdk/deepgram@2.0.20", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-eCRwZt5LgpdbWJAMsBKxbIG0XsE0mhiziIGTCVBU3ZlhKEYozidoRkpgTirr7bmy8Qvj+4SvcKyel35M5vuliQ=="],
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hn2y8Q+2iZgGNVJyzPsH8EECECryFMVmxBJrBvBWoi8xcJPRyt0fZP5dOSLyGg3q0oxmPS9M0Eq0NNlKot/bYQ=="],
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@2.0.34", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.30", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-nVZ/HgEHvLdx40aIJFvuTPQsGx2rHJSh8ZzSpx4E+fq6rL3D95QXWQ1xM81VXrj0uLCJp/eBhC9CZdMUpby7Ng=="],
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NiKjvqXI/96e/7SjZGgQH141PBqggsF7fNbjGTv4RgVWayMXp9mj0Ou2NjAUGwwxJwj/qseY0gXiDCYaHWFBkw=="],
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.20", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MAL04sDTOWUiBjAGWaVgyeE4bYRb9QpKYRlIeCTZFga6I8yQs50XakhWEssrmvVihdpHGkqpDtCHsFqCydsWLA=="],
"@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@1.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4d5EKu0OW7Gf5WFpGo4ixn0iWEwA+GpteqUjEznWGmi7qdLE5zdkbRik5B1HrDDiw5P90yO51xBex/Fp50JcVA=="],
"@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@2.0.20", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-er+hlxKBNMP168sdAgzFkoH1sWNcJssg7SP7TMSWO3TjMrJmNgUsQpNMDSYzoaP6F+PlZwINaG/fkKz8h5SVuw=="],
"@ai-sdk/fireworks": ["@ai-sdk/fireworks@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WWOz5Kj+5fVe94h7WeReqjUOVtAquDE2kM575FUc8CsVxH2tRfA5cLa8nu3bknSezsKt3i67YM6mvCRxiXCkWA=="],
"@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.34", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.30", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9SpijdfzyhS8XrutMu+hag4ST8LbDtoNaLuRMwfDR92JO4xyvSSVjhfuPvHsGQaXL+bcj0dUcQbB4bOyIv6z0A=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5Nrkj8B4MzkkOfjjA+Cs5pamkbkK4lI11bx80QV7TFcen/hWA8wEC+UVzwuM5H2zpekoNMjvl6GonHnR62XIZw=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zH1UbNRjG5woOXXFOrVCZraqZuFTtmPvLardMGcgLkzpxKV0U3tAGoyWKSZ862H+eBJfI/Hf2yj/zzGJcCkycg=="],
"@ai-sdk/google": ["@ai-sdk/google@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2XUnGi3f7TV4ujoAhA+Fg3idUoG/+Y2xjCRg70a1/m0DH1KSQqYaCboJ1C19y6ZHGdf5KNT20eJdswP6TvrY2g=="],
"@ai-sdk/google": ["@ai-sdk/google@3.0.29", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-x0hcU10AA+i1ZUQHloGD5qXWsB+Y8qnxlmFUef6Ly4rB53MGVbQExkI9nOKiCO3mu2TGiiNoQMeKWSeQVLfRUA=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.98", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/google": "2.0.52", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uuv0RHkdJ5vTzeH1+iuBlv7GAjRcOPd2jiqtGLz6IKOUDH+PRQoE3ExrvOysVnKuhhTBMqvawkktDhMDQE6sVQ=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.57", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.43", "@ai-sdk/google": "3.0.29", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-dVRGqC0Kgsb4KsLO4OSLELyse746KOwYibqP62FpRlVqPeTnHZPDzs8Dub4341p/WfUq+U0s98lpmH++M/4A1g=="],
"@ai-sdk/groq": ["@ai-sdk/groq@2.0.34", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wfCYkVgmVjxNA32T57KbLabVnv9aFUflJ4urJ7eWgTwbnmGQHElCTu+rJ3ydxkXSqxOkXPwMOttDm7XNrvPjmg=="],
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.24", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-J6UMMVKBDf1vxYN8TS4nBzCEImhon1vuqpJYkRYdbxul6Hlf0r0pT5/+1AD1nbQ1SJsOPlDqMRSYJuBnNYrNfQ=="],
"@ai-sdk/mistral": ["@ai-sdk/mistral@2.0.27", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gaptHgaXjMw3+eA0Q4FABcsj5nQNP6EpFaGUR+Pj5WJy7Kn6mApl975/x57224MfeJIShNpt8wFKK3tvh5ewKg=="],
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oZcx2pE6nJ+Qj/U6HFV5mJ52jXJPBSpvki/NtIocZkI/rKxphKBaecOH1h0Y7yK3HIbBxsMqefB1pb72cAHGVg=="],
"@ai-sdk/openai": ["@ai-sdk/openai@2.0.2", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-D4zYz2uR90aooKQvX1XnS00Z7PkbrcY+snUvPfm5bCabTG7bzLrVtD56nJ5bSaZG8lmuOMfXpyiEEArYLyWPpw=="],
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.1", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-luHVcU+yKzwv3ekKgbP3v+elUVxb2Rt+8c6w9qi7g2NYG2/pEL21oIrnaEnc6UtTZLLZX9EFBcpq2N1FQKDIMw=="],
"@ai-sdk/perplexity": ["@ai-sdk/perplexity@2.0.23", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-aiaRvnc6mhQZKhTTSXPCjPH8Iqr5D/PfCN1hgVP/3RGTBbJtsd9HemIBSABeSdAKbsMH/PwJxgnqH75HEamcBA=="],
"@ai-sdk/perplexity": ["@ai-sdk/perplexity@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7yxvyw0OFlHjCXgf+BDgmjefQmSk9FxSF5DPiFtLrow1zzLcvZvh6fkKEx+kgRQXNKhV9vvtH0U4NyVXgGMr0g=="],
"@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="],
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="],
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.34", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jjJmJms6kdEc4nC3MDGFJfhV8F1ifY4nolV2dbnT7BM4ab+Wkskc0GwCsJ7G7WdRMk7xDbFh4he3DPL8KJ/cyA=="],
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@2.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.30", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-mVQxam47CESgt0PNEdPM+WrVLCCLRO2KDAxs+bUo+27EMH8ag5CbAYj4zNF1793guJmUluRH0JqwN0mX6exHjg=="],
"@ai-sdk/vercel": ["@ai-sdk/vercel@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Qwjm+HdwKasu7L9bDUryBMGKDMscIEzMUkjw/33uGdJpktzyNW13YaNIObOZ2HkskqDMIQJSd4Ao2BBT8fEYLw=="],
"@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.32", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.30", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-QEAon7jsoM27TRWr/3AZl0b9U71JiYyeNWL+LkvYr+m3moyenSJz9f6CVHO7bp/YO9pWfjYfE3IZ2aIV+1Uq1g=="],
"@ai-sdk/xai": ["@ai-sdk/xai@2.0.51", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-AI3le03qiegkZvn9hpnpDwez49lOvQLj4QUBT8H41SMbrdTYOxn3ktTwrsSu90cNDdzKGMvoH0u2GHju1EdnCg=="],
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.56", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.30", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EbceQ0YkrAi0y4TRxFkgNgc/y8KhJhf+Gtxvmn7+hoV7OETP/q9Q3pgZLcLkLAHptM4hlliOMYpZux/9ADuY2Q=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
@@ -1273,27 +1273,25 @@
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.5.4", "", { "dependencies": { "@openrouter/sdk": "^0.1.27" }, "peerDependencies": { "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" } }, "sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw=="],
"@openrouter/sdk": ["@openrouter/sdk@0.1.27", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ=="],
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.2.3", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-NovC+BaCfEeJwhToDrs8JeDYXXlJdEyz7lcxkjtyePSE4eoAKik872SyDK0MzXKcz8MRkv7XlNhPI6zz4TQp0g=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.77", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.77", "@opentui/core-darwin-x64": "0.1.77", "@opentui/core-linux-arm64": "0.1.77", "@opentui/core-linux-x64": "0.1.77", "@opentui/core-win32-arm64": "0.1.77", "@opentui/core-win32-x64": "0.1.77", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-lE3kabm6jdqK3AuBq+O0zZrXdxt6ulmibTc57sf+AsPny6cmwYHnWI4wD6hcreFiYoQVNVvdiJchVgPtowMlEg=="],
"@opentui/core": ["@opentui/core@0.1.79", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.79", "@opentui/core-darwin-x64": "0.1.79", "@opentui/core-linux-arm64": "0.1.79", "@opentui/core-linux-x64": "0.1.79", "@opentui/core-win32-arm64": "0.1.79", "@opentui/core-win32-x64": "0.1.79", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-job/t09w8A/aHb/WuaVbimu5fIffyN+PCuVO5cYhXEg/NkOkC/WdFi80B8bwncR/DBPyLAh6oJ3EG86grOVo5g=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.77", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SNqmygCMEsPCW7xWjzCZ5caBf36xaprwVdAnFijGDOuIzLA4iaDa6um8cj3TJh7awenN3NTRsuRc7OuH42UH+g=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.79", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kgsGniV+DM5G1P3GideyJhvfnthNKcVCAm2mPTIr9InQ3L0gS/Feh7zgwOS/jxDvdlQbOWGKMk2Z3JApeC1MLw=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.77", "", { "os": "darwin", "cpu": "x64" }, "sha512-/8fsa03swEHTQt/9NrGm98kemlU+VuTURI/OFZiH53vPDRrOYIYoa4Jyga/H7ZMcG+iFhkq97zIe+0Kw95LGmA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.79", "", { "os": "darwin", "cpu": "x64" }, "sha512-OpyAmFqAAKQ2CeFmf/oLWcNksmP6Ryx/3R5dbKXThOudMCeQvfvInJTRbc2jTn9VFpf+Qj4BgHkJg1h90tf/EA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.77", "", { "os": "linux", "cpu": "arm64" }, "sha512-QfUXZJPc69OvqoMu+AlLgjqXrwu4IeqcBuUWYMuH8nPTeLsVUc3CBbXdV2lv9UDxWzxzrxdS4ALPaxvmEv9lsQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.79", "", { "os": "linux", "cpu": "arm64" }, "sha512-DCa5YaknS4bWhFt8TMEGH+qmTinyzuY8hoZbO4crtWXAxofPP7Pas76Cwxlvis/PyLffA+pPxAl1l5sUZpsvqw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.77", "", { "os": "linux", "cpu": "x64" }, "sha512-Kmfx0yUKnPj67AoXYIgL7qQo0QVsUG5Iw8aRtv6XFzXqa5SzBPhaKkKZ9yHPjOmTalZquUs+9zcCRNKpYYuL7A=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.79", "", { "os": "linux", "cpu": "x64" }, "sha512-V6xjvFfHh3NGvsuuDae1KHPRZXHMEE8XL0A/GM6v4I4OCC23kDmkK60Vn6OptQwAzwwbz0X0IX+Ut/GQU9qGgA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.77", "", { "os": "win32", "cpu": "arm64" }, "sha512-HGTscPXc7gdd23Nh1DbzUNjog1I+5IZp95XPtLftGTpjrWs60VcetXcyJqK2rQcXNxewJK5yDyaa5QyMjfEhCQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.79", "", { "os": "win32", "cpu": "arm64" }, "sha512-sPRKnVzOdT5szI59tte7pxwwkYA+07EQN+6miFAvkFuiLmRUngONUD8HVjL7nCnxcPFqxaU4Rvl1y40ST86g8g=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.77", "", { "os": "win32", "cpu": "x64" }, "sha512-c7GijsbvVgnlzd2murIbwuwrGbcv76KdUw6WlVv7a0vex50z6xJCpv1keGzpe0QfxrZ/6fFEFX7JnwGLno0wjA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.79", "", { "os": "win32", "cpu": "x64" }, "sha512-vmQcFTvKf9fqajnDtgU6/uAsiTGwx8//khqHVBmiTEXUsiT792Ki9l8sgNughbuldqG5iZOiF6IaAWU1H67UpA=="],
"@opentui/solid": ["@opentui/solid@0.1.77", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.77", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-JY+hUbXVV+XCk6bC8dvcwawWCEmC3Gid6GDs23AJWBgHZ3TU2kRKrgwTdltm45DOq2cZXrYCt690/yE8bP+Gxg=="],
"@opentui/solid": ["@opentui/solid@0.1.79", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.79", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-c5+0jexKxb8GwRDDkQ/U6isZZqClAzHccXmYiLYmSnqdoQQp2lIGHLartL+K8lfIQrsKClzP2ZHumN6nexRfRg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1409,7 +1407,7 @@
"@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
"@pierre/diffs": ["@pierre/diffs@1.0.2", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/transformers": "3.19.0", "diff": "8.0.2", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.19.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-RkFSDD5X/U+8QjyilPViYGJfmJNWXR17zTL8zw48+DcVC1Ujbh6I1edyuRnFfgRzpft05x2DSCkz2cjoIAxPvQ=="],
"@pierre/diffs": ["@pierre/diffs@1.1.0-beta.13", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-D35rxDu5V7XHX5aVGU6PF12GhscL+I+9QYgxK/i3h0d2XSirAxDdVNm49aYwlOhgmdvL0NbS1IHxPswVB5yJvw=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
@@ -1853,7 +1851,7 @@
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
@@ -2021,9 +2019,9 @@
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"ai": ["ai@5.0.124", "", { "dependencies": { "@ai-sdk/gateway": "2.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Li6Jw9F9qsvFJXZPBfxj38ddP2iURCnMs96f9Q3OeQzrDVcl1hvtwSEAuxA/qmfh6SDV2ERqFUOFzigvr0697g=="],
"ai": ["ai@6.0.86", "", { "dependencies": { "@ai-sdk/gateway": "3.0.46", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-U2W2LBCHA/pr0Ui7vmmsjBiLEzBbZF3yVHNy7Rbzn7IX+SvoQPFM5rN74hhfVzZoE8zBuGD4nLLk+j0elGacvQ=="],
"ai-gateway-provider": ["ai-gateway-provider@2.3.1", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.19", "ai": "^5.0.116" }, "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^3.0.71", "@ai-sdk/anthropic": "^2.0.56", "@ai-sdk/azure": "^2.0.90", "@ai-sdk/cerebras": "^1.0.33", "@ai-sdk/cohere": "^2.0.21", "@ai-sdk/deepgram": "^1.0.21", "@ai-sdk/deepseek": "^1.0.32", "@ai-sdk/elevenlabs": "^1.0.21", "@ai-sdk/fireworks": "^1.0.30", "@ai-sdk/google": "^2.0.51", "@ai-sdk/google-vertex": "3.0.90", "@ai-sdk/groq": "^2.0.33", "@ai-sdk/mistral": "^2.0.26", "@ai-sdk/openai": "^2.0.88", "@ai-sdk/perplexity": "^2.0.22", "@ai-sdk/xai": "^2.0.42", "@openrouter/ai-sdk-provider": "^1.5.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^1.0.29" } }, "sha512-PqI6TVNEDNwr7kOhy7XUGnA8XJB1SpeA9aLqGjr0CyWkKgH+y+ofPm8MZGZ74DOwVejDF+POZq0Qs9jKEKUeYg=="],
"ai-gateway-provider": ["ai-gateway-provider@3.1.1", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.37", "@ai-sdk/anthropic": "^3.0.29", "@ai-sdk/azure": "^3.0.21", "@ai-sdk/cerebras": "^2.0.23", "@ai-sdk/cohere": "^3.0.12", "@ai-sdk/deepgram": "^2.0.12", "@ai-sdk/deepseek": "^2.0.12", "@ai-sdk/elevenlabs": "^2.0.12", "@ai-sdk/fireworks": "^2.0.22", "@ai-sdk/google": "^3.0.16", "@ai-sdk/google-vertex": "^4.0.35", "@ai-sdk/groq": "^3.0.16", "@ai-sdk/mistral": "^3.0.13", "@ai-sdk/openai": "^3.0.21", "@ai-sdk/perplexity": "^3.0.12", "@ai-sdk/xai": "^3.0.40", "@openrouter/ai-sdk-provider": "^2.1.1" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-RTHkboNz9JocplBSKdDLSMEiN3OkfS1UeZqM93zuQ3BVDR5BmTisThoQR/3pkAVACIYL/8OEQ3kuSuDo820gVw=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
@@ -2181,7 +2179,7 @@
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
@@ -4151,7 +4149,7 @@
"@actions/http-client/undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.43", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hfhOa13HqNYIe2GtC7blUMVOKrmt5RzxbE7StFGXKN3hIn4xuppLLQY/gFOr+oEhQw1pO8j6CpffrRrWYz1mVQ=="],
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="],
@@ -4159,15 +4157,15 @@
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.28", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-m2Dm6fwUzMksqnPrd5f/WZ4cZ9GTZHpzsVO6jxKQwwc84gFHzAFZmUCG0C5mV7XlPOw4mwaiYV3HfLiEfphvvA=="],
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iTjumHf1/u4NhjXYFn/aONM2GId3/o7J1Lp5ql8FCbgIMyRwrmanR5xy1S3aaVkfTscuDvLTzWiy1mAbGzK3nQ=="],
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iTjumHf1/u4NhjXYFn/aONM2GId3/o7J1Lp5ql8FCbgIMyRwrmanR5xy1S3aaVkfTscuDvLTzWiy1mAbGzK3nQ=="],
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iTjumHf1/u4NhjXYFn/aONM2GId3/o7J1Lp5ql8FCbgIMyRwrmanR5xy1S3aaVkfTscuDvLTzWiy1mAbGzK3nQ=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.43", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hfhOa13HqNYIe2GtC7blUMVOKrmt5RzxbE7StFGXKN3hIn4xuppLLQY/gFOr+oEhQw1pO8j6CpffrRrWYz1mVQ=="],
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
@@ -4177,11 +4175,13 @@
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iTjumHf1/u4NhjXYFn/aONM2GId3/o7J1Lp5ql8FCbgIMyRwrmanR5xy1S3aaVkfTscuDvLTzWiy1mAbGzK3nQ=="],
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
"@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iTjumHf1/u4NhjXYFn/aONM2GId3/o7J1Lp5ql8FCbgIMyRwrmanR5xy1S3aaVkfTscuDvLTzWiy1mAbGzK3nQ=="],
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iTjumHf1/u4NhjXYFn/aONM2GId3/o7J1Lp5ql8FCbgIMyRwrmanR5xy1S3aaVkfTscuDvLTzWiy1mAbGzK3nQ=="],
"@astrojs/check/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
@@ -4387,13 +4387,9 @@
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
"@pierre/diffs/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
"@pierre/diffs/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-ZfWJNm2VMhKkQIKT9qXbs76RRcT0SF/CAvEz0+RkpUDAoDaCx0uFdCGzSRiD9gSlhm6AHkjdieOBJMaO2eC1rQ=="],
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.19.0", "", { "dependencies": { "@shikijs/core": "3.19.0", "@shikijs/types": "3.19.0" } }, "sha512-e6vwrsyw+wx4OkcrDbL+FVCxwx8jgKiCoXzakVur++mIWVcgpzIi8vxf4/b4dVTYrV/nUx5RjinMf4tq8YV8Fw=="],
"@pierre/diffs/shiki": ["shiki@3.19.0", "", { "dependencies": { "@shikijs/core": "3.19.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/engine-oniguruma": "3.19.0", "@shikijs/langs": "3.19.0", "@shikijs/themes": "3.19.0", "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-77VJr3OR/VUZzPiStyRhADmO2jApMM0V2b1qf0RpfWya8Zr1PeZev5AEpPGAAKWdiYUtcZGBE4F5QvJml1PvWA=="],
"@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
@@ -4457,15 +4453,11 @@
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.43", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hfhOa13HqNYIe2GtC7blUMVOKrmt5RzxbE7StFGXKN3hIn4xuppLLQY/gFOr+oEhQw1pO8j6CpffrRrWYz1mVQ=="],
"ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.90", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.56", "@ai-sdk/google": "2.0.46", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C9MLe1KZGg1ZbupV2osygHtL5qngyCDA6ATatunyfTbIe8TXKG8HGni/3O6ifbnI5qxTidIn150Ox7eIFZVMYg=="],
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.28", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-m2Dm6fwUzMksqnPrd5f/WZ4cZ9GTZHpzsVO6jxKQwwc84gFHzAFZmUCG0C5mV7XlPOw4mwaiYV3HfLiEfphvvA=="],
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
"ai-gateway-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@2.0.56", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FGlqwWc3tAYqDHE8r8hQGQLcMiPUwgz90oU2QygUH930OWtCLapFkSu114DgVaIN/qoM1DUX+inv0Ee74Fgp5g=="],
"ai-gateway-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iTjumHf1/u4NhjXYFn/aONM2GId3/o7J1Lp5ql8FCbgIMyRwrmanR5xy1S3aaVkfTscuDvLTzWiy1mAbGzK3nQ=="],
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
@@ -4579,11 +4571,11 @@
"nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.43", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hfhOa13HqNYIe2GtC7blUMVOKrmt5RzxbE7StFGXKN3hIn4xuppLLQY/gFOr+oEhQw1pO8j6CpffrRrWYz1mVQ=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@3.0.28", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-m2Dm6fwUzMksqnPrd5f/WZ4cZ9GTZHpzsVO6jxKQwwc84gFHzAFZmUCG0C5mV7XlPOw4mwaiYV3HfLiEfphvvA=="],
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iTjumHf1/u4NhjXYFn/aONM2GId3/o7J1Lp5ql8FCbgIMyRwrmanR5xy1S3aaVkfTscuDvLTzWiy1mAbGzK3nQ=="],
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
@@ -4973,23 +4965,9 @@
"@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@pierre/diffs/@shikijs/core/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@pierre/diffs/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
"@pierre/diffs/@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.19.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="],
"@pierre/diffs/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-L7SrRibU7ZoYi1/TrZsJOFAnnHyLTE1SwHG1yNWjZIVCqjOEmCSuK2ZO9thnRbJG6TOkPp+Z963JmpCNw5nzvA=="],
"@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.19.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="],
"@pierre/diffs/shiki/@shikijs/core": ["@shikijs/core@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-L7SrRibU7ZoYi1/TrZsJOFAnnHyLTE1SwHG1yNWjZIVCqjOEmCSuK2ZO9thnRbJG6TOkPp+Z963JmpCNw5nzvA=="],
"@pierre/diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1hRxtYIJfJSZeM5ivbUXv9hcJP3PWRo5prG/V2sWwiubUKTa+7P62d2qxCW8jiVFX4pgRHhnHNp+qeR7Xl+6kg=="],
"@pierre/diffs/shiki/@shikijs/langs": ["@shikijs/langs@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0" } }, "sha512-dBMFzzg1QiXqCVQ5ONc0z2ebyoi5BKz+MtfByLm0o5/nbUu3Iz8uaTCa5uzGiscQKm7lVShfZHU1+OG3t5hgwg=="],
"@pierre/diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0" } }, "sha512-H36qw+oh91Y0s6OlFfdSuQ0Ld+5CgB/VE6gNPK+Hk4VRbVG/XQgkjnt4KzfnnoO6tZPtKJKHPjwebOCfjd6F8A=="],
"@pierre/diffs/shiki/@shikijs/types": ["@shikijs/types@3.19.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="],
"@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
"@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
@@ -5013,14 +4991,6 @@
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.46", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8PK6u4sGE/kXebd7ZkTp+0aya4kNqzoqpS5m7cHY2NfTK6fhPc6GNvE+MZIZIoHQTp5ed86wGBdeBPpFaaUtyg=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+5 -9
View File
@@ -166,14 +166,10 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
let logProcessor
if ($app.stage === "production" || $app.stage === "frank") {
const HONEYCOMB_API_KEY = new sst.Secret("HONEYCOMB_API_KEY")
logProcessor = new sst.cloudflare.Worker("LogProcessor", {
handler: "packages/console/function/src/log-processor.ts",
link: [HONEYCOMB_API_KEY],
})
}
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
handler: "packages/console/function/src/log-processor.ts",
link: [new sst.Secret("HONEYCOMB_API_KEY")],
})
new sst.cloudflare.x.SolidStart("Console", {
domain,
@@ -211,7 +207,7 @@ new sst.cloudflare.x.SolidStart("Console", {
transform: {
worker: {
placement: { mode: "smart" },
tailConsumers: logProcessor ? [{ service: logProcessor.nodes.worker.scriptName }] : [],
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
},
},
},
+15 -1
View File
@@ -130,7 +130,7 @@ else
needs_baseline=false
if [ "$arch" = "x64" ]; then
if [ "$os" = "linux" ]; then
if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
if ! grep -qwi avx2 /proc/cpuinfo 2>/dev/null; then
needs_baseline=true
fi
fi
@@ -141,6 +141,20 @@ else
needs_baseline=true
fi
fi
if [ "$os" = "windows" ]; then
ps="(Add-Type -MemberDefinition \"[DllImport(\"\"kernel32.dll\"\")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);\" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)"
out=""
if command -v powershell.exe >/dev/null 2>&1; then
out=$(powershell.exe -NoProfile -NonInteractive -Command "$ps" 2>/dev/null || true)
elif command -v pwsh >/dev/null 2>&1; then
out=$(pwsh -NoProfile -NonInteractive -Command "$ps" 2>/dev/null || true)
fi
out=$(echo "$out" | tr -d '\r' | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')
if [ "$out" != "true" ] && [ "$out" != "1" ]; then
needs_baseline=true
fi
fi
fi
target="$os-$arch"
@@ -1,15 +0,0 @@
{
"keep": {
"days": true,
"amount": 14
},
"auditLog": "/home/thdxr/dev/projects/sst/opencode/logs/.2c5480b3b2480f80fa29b850af461dce619c0b2f-audit.json",
"files": [
{
"date": 1759827172859,
"name": "/home/thdxr/dev/projects/sst/opencode/logs/mcp-puppeteer-2025-10-07.log",
"hash": "a3d98b26edd793411b968a0d24cfeee8332138e282023c3b83ec169d55c67f16"
}
],
"hashType": "sha256"
}
-48
View File
@@ -1,48 +0,0 @@
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:52.879"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:52.880"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:56.191"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:56.192"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:59.267"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:59.268"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:20.276"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:20.277"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:30.838"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:30.839"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:42.452"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:42.452"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:46.499"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:46.500"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:02.295"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:02.295"}
{"arguments":{"url":"https://google.com"},"level":"debug","message":"Tool call received","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:37.150","tool":"puppeteer_navigate"}
{"0":"n","1":"p","2":"x","level":"info","message":"Launching browser with config:","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:37.150"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:55:08.488"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:55:08.489"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:11.815"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:11.816"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:21.934"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:21.935"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:32.544"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:32.544"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:41.154"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:41.155"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:55.426"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:55.427"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:15.715"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:15.716"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:25.063"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:25.064"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:48.567"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:48.568"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:25:08.937"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:25:08.938"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:37.120"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:37.121"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:52.490"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:52.491"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:39:25.524"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:39:25.525"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:40:57.126"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:40:57.127"}
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:42:24.175"}
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:42:24.176"}
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-cvRBvHRuunNjF07c4GVHl5rRgoTn1qfI/HdJWtOV63M=",
"aarch64-linux": "sha256-DJUI4pMZ7wQTnyOiuDHALmZz7FZtrTbzRzCuNOShmWE=",
"aarch64-darwin": "sha256-JnkqDwuC7lNsjafV+jOGfvs8K1xC8rk5CTOW+spjiCA=",
"x86_64-darwin": "sha256-GBeTqq2vDn/mXplYNglrAT2xajjFVzB4ATHnMS0j7z4="
"x86_64-linux": "sha256-FsFTitxnN2brebZDBRGJB0NWTOVYDa/QcNRH0ip/Gk4=",
"aarch64-linux": "sha256-knSEqEPyonBUfmGZKTq5Om4HikItWbfPdfT7p6iljzs=",
"aarch64-darwin": "sha256-uRgWfuOlLECRCOszm8XhySiWxu9IdDhpSbosPZPAZVI=",
"x86_64-darwin": "sha256-gHuA+Ud9L+XLvKm5Vp5jCXfZWOtunnmX/lB8vczHsG0="
}
}
+4 -4
View File
@@ -4,7 +4,7 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.8",
"packageManager": "bun@1.3.9",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri dev",
@@ -23,7 +23,7 @@
"packages/slack"
],
"catalog": {
"@types/bun": "1.3.8",
"@types/bun": "1.3.9",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"ulid": "3.0.1",
@@ -35,12 +35,12 @@
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/diffs": "1.0.2",
"@pierre/diffs": "1.1.0-beta.13",
"@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"ai": "5.0.124",
"ai": "6.0.86",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
+83 -1
View File
@@ -1,6 +1,7 @@
import { test, expect } from "../fixtures"
import { openSidebar, withSession } from "../actions"
import { defocus, openSidebar, withSession } from "../actions"
import { promptSelector } from "../selectors"
import { modKey } from "../utils"
test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
await page.setViewportSize({ width: 1400, height: 800 })
@@ -40,3 +41,84 @@ test("titlebar back/forward navigates between sessions", async ({ page, slug, sd
})
})
})
test("titlebar forward is cleared after branching history from sidebar", async ({ page, slug, sdk, gotoSession }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const stamp = Date.now()
await withSession(sdk, `e2e titlebar history a ${stamp}`, async (a) => {
await withSession(sdk, `e2e titlebar history b ${stamp}`, async (b) => {
await withSession(sdk, `e2e titlebar history c ${stamp}`, async (c) => {
await gotoSession(a.id)
await openSidebar(page)
const second = page.locator(`[data-session-id="${b.id}"] a`).first()
await expect(second).toBeVisible()
await second.scrollIntoViewIfNeeded()
await second.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
const back = page.getByRole("button", { name: "Back" })
const forward = page.getByRole("button", { name: "Forward" })
await expect(back).toBeVisible()
await expect(back).toBeEnabled()
await back.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${a.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await openSidebar(page)
const third = page.locator(`[data-session-id="${c.id}"] a`).first()
await expect(third).toBeVisible()
await third.scrollIntoViewIfNeeded()
await third.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await expect(forward).toBeVisible()
await expect(forward).toBeDisabled()
})
})
})
})
test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, gotoSession }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const stamp = Date.now()
await withSession(sdk, `e2e titlebar shortcuts 1 ${stamp}`, async (one) => {
await withSession(sdk, `e2e titlebar shortcuts 2 ${stamp}`, async (two) => {
await gotoSession(one.id)
await openSidebar(page)
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(link).toBeVisible()
await link.scrollIntoViewIfNeeded()
await link.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await defocus(page)
await page.keyboard.press(`${modKey}+[`)
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await defocus(page)
await page.keyboard.press(`${modKey}+]`)
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
})
})
})
+16 -3
View File
@@ -1,15 +1,28 @@
import { test, expect } from "../fixtures"
import { openPalette, clickListItem } from "../actions"
import { promptSelector } from "../selectors"
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openPalette(page)
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
const command = page.locator('[data-slash-id="file.open"]').first()
await expect(command).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
await clickListItem(dialog, { keyStartsWith: "file:" })
const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
await expect(item).toBeVisible({ timeout: 30_000 })
await item.click()
await expect(dialog).toHaveCount(0)
+29 -17
View File
@@ -1,37 +1,49 @@
import { test, expect } from "../fixtures"
test.skip("file tree can expand folders and open a file", async ({ page, gotoSession }) => {
test("file tree can expand folders and open a file", async ({ page, gotoSession }) => {
await gotoSession()
const toggle = page.getByRole("button", { name: "Toggle file tree" })
const treeTabs = page.locator('[data-component="tabs"][data-variant="pill"][data-scope="filetree"]')
const panel = page.locator("#file-tree-panel")
const treeTabs = panel.locator('[data-component="tabs"][data-variant="pill"][data-scope="filetree"]')
await expect(toggle).toBeVisible()
if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click()
await expect(toggle).toHaveAttribute("aria-expanded", "true")
await expect(panel).toBeVisible()
await expect(treeTabs).toBeVisible()
await treeTabs.locator('[data-slot="tabs-trigger"]').nth(1).click()
const allTab = treeTabs.getByRole("tab", { name: /^all files$/i })
await expect(allTab).toBeVisible()
await allTab.click()
await expect(allTab).toHaveAttribute("aria-selected", "true")
const node = (name: string) => treeTabs.getByRole("button", { name, exact: true })
const tree = treeTabs.locator('[data-slot="tabs-content"]:not([hidden])')
await expect(tree).toBeVisible()
await expect(node("packages")).toBeVisible()
await node("packages").click()
const expand = async (name: string) => {
const folder = tree.getByRole("button", { name, exact: true }).first()
await expect(folder).toBeVisible()
await expect(folder).toHaveAttribute("aria-expanded", /true|false/)
if ((await folder.getAttribute("aria-expanded")) === "false") await folder.click()
await expect(folder).toHaveAttribute("aria-expanded", "true")
}
await expect(node("app")).toBeVisible()
await node("app").click()
await expand("packages")
await expand("app")
await expand("src")
await expand("components")
await expect(node("src")).toBeVisible()
await node("src").click()
await expect(node("components")).toBeVisible()
await node("components").click()
await expect(node("file-tree.tsx")).toBeVisible()
await node("file-tree.tsx").click()
const file = tree.getByRole("button", { name: "file-tree.tsx", exact: true }).first()
await expect(file).toBeVisible()
await file.click()
const tab = page.getByRole("tab", { name: "file-tree.tsx" })
await expect(tab).toBeVisible()
await tab.click()
await expect(tab).toHaveAttribute("aria-selected", "true")
const code = page.locator('[data-component="code"]').first()
await expect(code.getByText("export default function FileTree")).toBeVisible()
await expect(code).toBeVisible()
await expect(code).toContainText("export default function FileTree")
})
+30 -7
View File
@@ -1,18 +1,41 @@
import { test, expect } from "../fixtures"
import { openPalette, clickListItem } from "../actions"
import { promptSelector } from "../selectors"
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
await gotoSession()
const sep = process.platform === "win32" ? "\\" : "/"
const file = ["packages", "app", "package.json"].join(sep)
await page.locator(promptSelector).click()
await page.keyboard.type("/open")
const dialog = await openPalette(page)
const command = page.locator('[data-slash-id="file.open"]').first()
await expect(command).toBeVisible()
await page.keyboard.press("Enter")
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill(file)
await input.fill("package.json")
await clickListItem(dialog, { text: /packages.*app.*package.json/ })
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
let index = -1
await expect
.poll(
async () => {
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
return index >= 0
},
{ timeout: 30_000 },
)
.toBe(true)
const item = items.nth(index)
await expect(item).toBeVisible()
await item.click()
await expect(dialog).toHaveCount(0)
@@ -22,5 +45,5 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
const code = page.locator('[data-component="code"]').first()
await expect(code).toBeVisible()
await expect(code.getByText("@opencode-ai/app")).toBeVisible()
await expect(code.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
})
@@ -1,6 +1,6 @@
import { test, expect } from "../fixtures"
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem } from "../actions"
import { projectCloseHoverSelector, projectCloseMenuSelector, projectSwitchSelector } from "../selectors"
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions"
import { projectCloseHoverSelector, projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils"
test("can close a project via hover card close button", async ({ page, withProject }) => {
@@ -31,16 +31,15 @@ test("can close a project via hover card close button", async ({ page, withProje
}
})
test("can close a project via project header more options menu", async ({ page, withProject }) => {
test("closing active project navigates to another open project", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherName = other.split("/").pop() ?? other
const otherSlug = dirSlug(other)
try {
await withProject(
async () => {
async ({ slug }) => {
await openSidebar(page)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
@@ -49,21 +48,20 @@ test("can close a project via project header more options menu", async ({ page,
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
const header = page
.locator(".group\\/project")
.filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) })
.first()
await expect(header).toContainText(otherName)
const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first()
await expect(trigger).toHaveCount(1)
await trigger.focus()
await page.keyboard.press("Enter")
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
await expect(menu).toBeVisible({ timeout: 10_000 })
const menu = await openProjectMenu(page, otherSlug)
await clickMenuItem(menu, /^Close$/i, { force: true })
await expect
.poll(() => {
const pathname = new URL(page.url()).pathname
if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
if (pathname === "/") return "home"
return ""
})
.toMatch(/^(project|home)$/)
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
await expect(otherButton).toHaveCount(0)
},
{ extra: [other] },
@@ -69,15 +69,19 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await expect(prompt).toBeEditable()
await prompt.click()
await page.keyboard.type(text)
await page.keyboard.press("Enter")
await expect(prompt).toBeFocused()
await prompt.fill(text)
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
await prompt.press("Enter")
await expect.poll(() => slugFromUrl(page.url())).toBe(slug)
await expect(page).toHaveURL(new RegExp(`/${slug}/session/[^/?#]+`), { timeout: 30_000 })
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`))
return sessionID
}
+79 -3
View File
@@ -1,5 +1,6 @@
import { base64Decode } from "@opencode-ai/util/encode"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import type { Page } from "@playwright/test"
@@ -14,7 +15,8 @@ import {
openWorkspaceMenu,
setWorkspacesEnabled,
} from "../actions"
import { inlineInputSelector, workspaceItemSelector } from "../selectors"
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
import { createSdk, dirSlug } from "../utils"
function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
@@ -126,6 +128,49 @@ test("can create a workspace", async ({ page, withProject }) => {
})
})
test("non-git projects keep workspace mode disabled", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
const nonGitSlug = dirSlug(nonGit)
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
try {
await withProject(async () => {
await page.goto(`/${nonGitSlug}/session`)
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
const activeDir = base64Decode(slugFromUrl(page.url()))
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
await openSidebar(page)
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
const trigger = page.locator('[data-action="project-menu"]').first()
const hasMenu = await trigger
.isVisible()
.then((x) => x)
.catch(() => false)
if (!hasMenu) return
await trigger.click({ force: true })
const menu = page.locator(dropdownMenuContentSelector).first()
await expect(menu).toBeVisible()
const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first()
await expect(toggle).toBeVisible()
await expect(toggle).toBeDisabled()
await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
})
} finally {
await cleanupTestProject(nonGit)
}
})
test("can rename a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
@@ -214,14 +259,45 @@ test("can delete a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async (project) => {
const { rootSlug, slug } = await setupWorkspaceTest(page, project)
const sdk = createSdk(project.directory)
const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project)
await expect
.poll(
async () => {
const worktrees = await sdk.worktree
.list()
.then((r) => r.data ?? [])
.catch(() => [] as string[])
return worktrees.includes(directory)
},
{ timeout: 30_000 },
)
.toBe(true)
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Delete$/i, { force: true })
await confirmDialog(page, /^Delete workspace$/i)
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
await expect
.poll(
async () => {
const worktrees = await sdk.worktree
.list()
.then((r) => r.data ?? [])
.catch(() => [] as string[])
return worktrees.includes(directory)
},
{ timeout: 60_000 },
)
.toBe(false)
await project.gotoSession()
await openSidebar(page)
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
})
})
+79 -24
View File
@@ -1,40 +1,95 @@
import { test, expect } from "../fixtures"
import type { Page } from "@playwright/test"
import { promptSelector } from "../selectors"
import { withSession } from "../actions"
function contextButton(page: Page) {
return page
.locator('[data-component="button"]')
.filter({ has: page.locator('[data-component="progress-circle"]').first() })
.first()
}
async function seedContextSession(input: { sessionID: string; sdk: Parameters<typeof withSession>[0] }) {
await input.sdk.session.promptAsync({
sessionID: input.sessionID,
noReply: true,
parts: [
{
type: "text",
text: "seed context",
},
],
})
await expect
.poll(async () => {
const messages = await input.sdk.session
.messages({ sessionID: input.sessionID, limit: 1 })
.then((r) => r.data ?? [])
return messages.length
})
.toBeGreaterThan(0)
}
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
const title = `e2e smoke context ${Date.now()}`
await withSession(sdk, title, async (session) => {
await sdk.session.promptAsync({
sessionID: session.id,
noReply: true,
parts: [
{
type: "text",
text: "seed context",
},
],
})
await expect
.poll(async () => {
const messages = await sdk.session.messages({ sessionID: session.id, limit: 1 }).then((r) => r.data ?? [])
return messages.length
})
.toBeGreaterThan(0)
await seedContextSession({ sessionID: session.id, sdk })
await gotoSession(session.id)
const contextButton = page
.locator('[data-component="button"]')
.filter({ has: page.locator('[data-component="progress-circle"]').first() })
.first()
await expect(contextButton).toBeVisible()
await contextButton.click()
const trigger = contextButton(page)
await expect(trigger).toBeVisible()
await trigger.click()
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
})
})
test("context panel can be closed from the context tab close action", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, `e2e context toggle ${Date.now()}`, async (session) => {
await seedContextSession({ sessionID: session.id, sdk })
await gotoSession(session.id)
await page.locator(promptSelector).click()
const trigger = contextButton(page)
await expect(trigger).toBeVisible()
await trigger.click()
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
const context = tabs.getByRole("tab", { name: "Context" })
await expect(context).toBeVisible()
await page.getByRole("button", { name: "Close tab" }).first().click()
await expect(context).toHaveCount(0)
})
})
test("context panel can open file picker from context actions", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, `e2e context tabs ${Date.now()}`, async (session) => {
await seedContextSession({ sessionID: session.id, sdk })
await gotoSession(session.id)
await page.locator(promptSelector).click()
const trigger = contextButton(page)
await expect(trigger).toBeVisible()
await trigger.click()
await expect(page.getByRole("tab", { name: "Context" })).toBeVisible()
await page.getByRole("button", { name: "Open file" }).first().click()
const dialog = page
.getByRole("dialog")
.filter({ has: page.getByPlaceholder(/search files/i) })
.first()
await expect(dialog).toBeVisible()
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)
})
})
@@ -0,0 +1,43 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { sessionIDFromUrl } from "../actions"
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
// the connection open while the agent works, causing "Failed to fetch" over
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
// Simulate Tailscale/VPN killing the long-lived sync connection
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
await gotoSession()
const token = `E2E_ASYNC_${Date.now()}`
await page.locator(promptSelector).click()
await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())!
try {
// Agent response arrives via SSE despite sync endpoint being dead
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
return messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
},
{ timeout: 90_000 },
)
.toContain(token)
} finally {
await sdk.session.delete({ sessionID }).catch(() => undefined)
}
})
-3
View File
@@ -44,9 +44,6 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
)
.toContain(token)
const reply = page.locator('[data-slot="session-turn-summary-section"]').filter({ hasText: token }).first()
await expect(reply).toBeVisible({ timeout: 90_000 })
} finally {
page.off("pageerror", onPageError)
await sdk.session.delete({ sessionID }).catch(() => undefined)
+3
View File
@@ -10,8 +10,11 @@ export const settingsNotificationsAgentSelector = '[data-action="settings-notifi
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]'
export const settingsSoundsAgentEnabledSelector = '[data-action="settings-sounds-agent-enabled"]'
export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]'
export const settingsSoundsPermissionsEnabledSelector = '[data-action="settings-sounds-permissions-enabled"]'
export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]'
export const settingsSoundsErrorsEnabledSelector = '[data-action="settings-sounds-errors-enabled"]'
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
@@ -10,39 +10,42 @@ async function seedConversation(input: {
sessionID: string
token: string
}) {
const messages = async () =>
await input.sdk.session.messages({ sessionID: input.sessionID, limit: 100 }).then((r) => r.data ?? [])
const seeded = await messages()
const userIDs = new Set(seeded.filter((m) => m.info.role === "user").map((m) => m.info.id))
const prompt = input.page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.click()
await input.page.keyboard.type(`Reply with exactly: ${input.token}`)
await input.page.keyboard.press("Enter")
await input.sdk.session.promptAsync({
sessionID: input.sessionID,
noReply: true,
parts: [{ type: "text", text: input.token }],
})
let userMessageID: string | undefined
await expect
.poll(
async () => {
const messages = await input.sdk.session
.messages({ sessionID: input.sessionID, limit: 50 })
.then((r) => r.data ?? [])
const users = messages.filter((m) => m.info.role === "user")
const users = (await messages()).filter(
(m) =>
!userIDs.has(m.info.id) &&
m.info.role === "user" &&
m.parts.filter((p) => p.type === "text").some((p) => p.text.includes(input.token)),
)
if (users.length === 0) return false
const user = users.reduce((acc, item) => (item.info.id > acc.info.id ? item : acc))
const user = users[users.length - 1]
if (!user) return false
userMessageID = user.info.id
const assistantText = messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
return assistantText.includes(input.token)
return true
},
{ timeout: 90_000 },
{ timeout: 90_000, intervals: [250, 500, 1_000] },
)
.toBe(true)
if (!userMessageID) throw new Error("Expected a user message id")
await expect(input.page.locator(`[data-message-id="${userMessageID}"]`).first()).toBeVisible({ timeout: 30_000 })
return { prompt, userMessageID }
}
@@ -124,3 +127,107 @@ test("slash redo clears revert and restores latest state", async ({ page, withPr
})
})
})
test("slash undo/redo traverses multi-step revert stack", async ({ page, withProject }) => {
test.setTimeout(120_000)
const firstToken = `undo_redo_first_${Date.now()}`
const secondToken = `undo_redo_second_${Date.now()}`
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
await project.gotoSession(session.id)
const first = await seedConversation({
page,
sdk,
sessionID: session.id,
token: firstToken,
})
const second = await seedConversation({
page,
sdk,
sessionID: session.id,
token: secondToken,
})
expect(first.userMessageID).not.toBe(second.userMessageID)
const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
await expect(firstMessage.first()).toBeVisible()
await expect(secondMessage.first()).toBeVisible()
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/undo")
const undo = page.locator('[data-slash-id="session.undo"]').first()
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(second.userMessageID)
await expect(firstMessage.first()).toBeVisible()
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/undo")
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(first.userMessageID)
await expect(firstMessage).toHaveCount(0)
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/redo")
const redo = page.locator('[data-slash-id="session.redo"]').first()
await expect(redo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(second.userMessageID)
await expect(firstMessage.first()).toBeVisible()
await expect(secondMessage).toHaveCount(0)
await second.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/redo")
await expect(redo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBeUndefined()
await expect(firstMessage.first()).toBeVisible()
await expect(secondMessage.first()).toBeVisible()
})
})
})
+31 -14
View File
@@ -34,21 +34,34 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
const stamp = Date.now()
const originalTitle = `e2e rename test ${stamp}`
const newTitle = `e2e renamed ${stamp}`
const renamedTitle = `e2e renamed ${stamp}`
await withSession(sdk, originalTitle, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i)
const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await input.fill(newTitle)
await expect(input).toBeFocused()
await input.fill(renamedTitle)
await expect(input).toHaveValue(renamedTitle)
await input.press("Enter")
await expect(page.getByRole("heading", { level: 1 }).first()).toContainText(newTitle)
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.title
},
{ timeout: 30_000 },
)
.toBe(renamedTitle)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
})
})
@@ -116,8 +129,14 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
await seedMessage(sdk, session.id)
await gotoSession(session.id)
const { rightSection, popoverBody } = await openSharePopover(page)
await popoverBody.getByRole("button", { name: "Publish" }).first().click()
const shared = await openSharePopover(page)
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
await expect(publish).toBeVisible({ timeout: 30_000 })
await publish.click()
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
timeout: 30_000,
})
await expect
.poll(
@@ -129,14 +148,14 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
)
.not.toBeUndefined()
const copyButton = rightSection.locator('button[aria-label="Copy link"]').first()
await expect(copyButton).toBeVisible({ timeout: 30_000 })
const sharedPopover = await openSharePopover(page)
const unpublish = sharedPopover.popoverBody.getByRole("button", { name: "Unpublish" }).first()
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
await expect(unpublish).toBeVisible({ timeout: 30_000 })
await unpublish.click()
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000,
})
await expect
.poll(
async () => {
@@ -147,10 +166,8 @@ test("session can be shared and unshared via header button", async ({ page, sdk,
)
.toBeUndefined()
await expect(copyButton).not.toBeVisible({ timeout: 30_000 })
const unsharedPopover = await openSharePopover(page)
await expect(unsharedPopover.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
const unshared = await openSharePopover(page)
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000,
})
})
@@ -9,7 +9,7 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle")).first()
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
@@ -51,6 +51,40 @@ test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
expect(finalClosed).toBe(initiallyClosed)
})
test("sidebar toggle keybind guards against shortcut conflicts", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("B")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyP`)
await page.waitForTimeout(100)
const toast = page.locator('[data-component="toast"]').last()
await expect(toast).toBeVisible()
await expect(toast).toContainText(/already/i)
await keybindButton.click()
await expect(keybindButton).toContainText("B")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["sidebar.toggle"]).toBeUndefined()
await closeDialog(page, dialog)
})
test("resetting all keybinds to defaults works", async ({ page, gotoSession }) => {
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "sidebar.toggle": "mod+shift+x" } }))
@@ -277,6 +311,44 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) =>
await expect(terminal).not.toBeVisible()
})
test("terminal toggle keybind persists after reload", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("terminal.toggle"))
await expect(keybindButton).toBeVisible()
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyY`)
await page.waitForTimeout(100)
await expect(keybindButton).toContainText("Y")
await closeDialog(page, dialog)
await page.reload()
await expect
.poll(async () => {
return await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
if (!raw) return
const parsed = JSON.parse(raw)
return parsed?.keybinds?.["terminal.toggle"]
})
})
.toBe("mod+shift+y")
const reloaded = await openSettings(page)
await reloaded.getByRole("tab", { name: "Shortcuts" }).click()
const reloadedKeybind = reloaded.locator(keybindButtonSelector("terminal.toggle")).first()
await expect(reloadedKeybind).toContainText("Y")
await closeDialog(page, reloaded)
})
test("changing command palette keybind works", async ({ page, gotoSession }) => {
await gotoSession()
+187
View File
@@ -9,6 +9,9 @@ import {
settingsNotificationsPermissionsSelector,
settingsReleaseNotesSelector,
settingsSoundsAgentSelector,
settingsSoundsAgentEnabledSelector,
settingsSoundsErrorsSelector,
settingsSoundsPermissionsSelector,
settingsThemeSelector,
settingsUpdatesStartupSelector,
} from "../selectors"
@@ -139,6 +142,105 @@ test("changing font persists in localStorage and updates CSS variable", async ({
expect(newFontFamily).not.toBe(initialFontFamily)
})
test("color scheme and font rehydrate after reload", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const colorSchemeSelect = dialog.locator(settingsColorSchemeSelector)
await expect(colorSchemeSelect).toBeVisible()
await colorSchemeSelect.locator('[data-slot="select-select-trigger"]').click()
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
const fontSelect = dialog.locator(settingsFontSelector)
await expect(fontSelect).toBeVisible()
const initialFontFamily = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
})
const initialSettings = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
const currentFont =
(await fontSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
await fontSelect.locator('[data-slot="select-select-trigger"]').click()
const fontItems = page.locator('[data-slot="select-select-item"]')
expect(await fontItems.count()).toBeGreaterThan(1)
if (currentFont) {
await fontItems.filter({ hasNotText: currentFont }).first().click()
}
if (!currentFont) {
await fontItems.nth(1).click()
}
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
appearance: {
font: expect.any(String),
},
})
const updatedSettings = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
const updatedFontFamily = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
})
expect(updatedFontFamily).not.toBe(initialFontFamily)
expect(updatedSettings?.appearance?.font).not.toBe(initialSettings?.appearance?.font)
await closeDialog(page, dialog)
await page.reload()
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
appearance: {
font: updatedSettings?.appearance?.font,
},
})
const rehydratedSettings = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
await expect
.poll(async () => {
return await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
})
})
.not.toBe(initialFontFamily)
const rehydratedFontFamily = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
})
expect(rehydratedFontFamily).not.toBe(initialFontFamily)
expect(rehydratedSettings?.appearance?.font).toBe(updatedSettings?.appearance?.font)
})
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
@@ -234,6 +336,91 @@ test("changing sound agent selection persists in localStorage", async ({ page, g
expect(stored?.sounds?.agent).not.toBe("staplebops-01")
})
test("disabling agent sound disables sound selection", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const select = dialog.locator(settingsSoundsAgentSelector)
const switchContainer = dialog.locator(settingsSoundsAgentEnabledSelector)
const trigger = select.locator('[data-slot="select-select-trigger"]')
await expect(select).toBeVisible()
await expect(switchContainer).toBeVisible()
await expect(trigger).toBeEnabled()
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
await expect(trigger).toBeDisabled()
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.sounds?.agentEnabled).toBe(false)
})
test("changing permissions and errors sounds updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const permissionsSelect = dialog.locator(settingsSoundsPermissionsSelector)
const errorsSelect = dialog.locator(settingsSoundsErrorsSelector)
await expect(permissionsSelect).toBeVisible()
await expect(errorsSelect).toBeVisible()
const initial = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
const permissionsCurrent =
(await permissionsSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
await permissionsSelect.locator('[data-slot="select-select-trigger"]').click()
const permissionItems = page.locator('[data-slot="select-select-item"]')
expect(await permissionItems.count()).toBeGreaterThan(1)
if (permissionsCurrent) {
await permissionItems.filter({ hasNotText: permissionsCurrent }).first().click()
}
if (!permissionsCurrent) {
await permissionItems.nth(1).click()
}
const errorsCurrent =
(await errorsSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
await errorsSelect.locator('[data-slot="select-select-trigger"]').click()
const errorItems = page.locator('[data-slot="select-select-item"]')
expect(await errorItems.count()).toBeGreaterThan(1)
if (errorsCurrent) {
await errorItems.filter({ hasNotText: errorsCurrent }).first().click()
}
if (!errorsCurrent) {
await errorItems.nth(1).click()
}
await expect
.poll(async () => {
return await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
})
.toMatchObject({
sounds: {
permissions: expect.any(String),
errors: expect.any(String),
},
})
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.sounds?.permissions).not.toBe(initial?.sounds?.permissions)
expect(stored?.sounds?.errors).not.toBe(initial?.sounds?.errors)
})
test("toggling updates startup switch updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
@@ -0,0 +1,36 @@
import { test, expect } from "../fixtures"
import { closeSidebar, hoverSessionItem } from "../actions"
import { projectSwitchSelector, sessionItemSelector } from "../selectors"
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
const stamp = Date.now()
const one = await sdk.session.create({ title: `e2e sidebar popover archive 1 ${stamp}` }).then((r) => r.data)
const two = await sdk.session.create({ title: `e2e sidebar popover archive 2 ${stamp}` }).then((r) => r.data)
if (!one?.id) throw new Error("Session create did not return an id")
if (!two?.id) throw new Error("Session create did not return an id")
try {
await gotoSession(one.id)
await closeSidebar(page)
const project = page.locator(projectSwitchSelector(slug)).first()
await expect(project).toBeVisible()
await project.hover()
await expect(page.locator(sessionItemSelector(one.id)).first()).toBeVisible()
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
const item = await hoverSessionItem(page, one.id)
await item
.getByRole("button", { name: /archive/i })
.first()
.click()
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
} finally {
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
}
})
+24 -1
View File
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
import { openSidebar, toggleSidebar } from "../actions"
import { openSidebar, toggleSidebar, withSession } from "../actions"
test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
await gotoSession()
@@ -12,3 +12,26 @@ test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
await toggleSidebar(page)
await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
})
test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, "sidebar persist session 1", async (session1) => {
await withSession(sdk, "sidebar persist session 2", async (session2) => {
await gotoSession(session1.id)
await openSidebar(page)
await toggleSidebar(page)
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
await gotoSession(session2.id)
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
await page.reload()
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
const opened = await page.evaluate(
() => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened,
)
await expect(opened).toBe(false)
})
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.55",
"version": "1.1.65",
"description": "",
"type": "module",
"exports": {
+98 -70
View File
@@ -1,5 +1,5 @@
import "@/index.css"
import { ErrorBoundary, Show, lazy, type ParentProps } from "solid-js"
import { ErrorBoundary, Show, Suspense, lazy, type JSX, type ParentProps } from "solid-js"
import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font"
@@ -30,12 +30,26 @@ import { HighlightsProvider } from "@/context/highlights"
import Layout from "@/pages/layout"
import DirectoryLayout from "@/pages/directory-layout"
import { ErrorPage } from "./pages/error"
import { Suspense, JSX } from "solid-js"
const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
const Loading = () => <div class="size-full" />
const HomeRoute = () => (
<Suspense fallback={<Loading />}>
<Home />
</Suspense>
)
const SessionRoute = () => (
<SessionProviders>
<Suspense fallback={<Loading />}>
<Session />
</Suspense>
</SessionProviders>
)
const SessionIndexRoute = () => <Navigate href="session" />
function UiI18nBridge(props: ParentProps) {
const language = useLanguage()
return <I18nProvider value={{ locale: language.locale, t: language.t }}>{props.children}</I18nProvider>
@@ -43,7 +57,7 @@ function UiI18nBridge(props: ParentProps) {
declare global {
interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[] }
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[]; wsl?: boolean }
}
}
@@ -52,6 +66,71 @@ function MarkedProviderWithNativeParser(props: ParentProps) {
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
}
function AppShellProviders(props: ParentProps) {
return (
<SettingsProvider>
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
<ModelsProvider>
<CommandProvider>
<HighlightsProvider>
<Layout>{props.children}</Layout>
</HighlightsProvider>
</CommandProvider>
</ModelsProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
</SettingsProvider>
)
}
function SessionProviders(props: ParentProps) {
return (
<TerminalProvider>
<FileProvider>
<PromptProvider>
<CommentsProvider>{props.children}</CommentsProvider>
</PromptProvider>
</FileProvider>
</TerminalProvider>
)
}
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
return (
<AppShellProviders>
{props.appChildren}
{props.children}
</AppShellProviders>
)
}
const getStoredDefaultServerUrl = (platform: ReturnType<typeof usePlatform>) => {
if (platform.platform !== "web") return
const result = platform.getDefaultServerUrl?.()
if (result instanceof Promise) return
if (!result) return
return normalizeServerUrl(result)
}
const resolveDefaultServerUrl = (props: {
defaultUrl?: string
storedDefaultServerUrl?: string
hostname: string
origin: string
isDev: boolean
devHost?: string
devPort?: string
}) => {
if (props.defaultUrl) return props.defaultUrl
if (props.storedDefaultServerUrl) return props.storedDefaultServerUrl
if (props.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (props.isDev) return `http://${props.devHost ?? "localhost"}:${props.devPort ?? "4096"}`
return props.origin
}
export function AppBaseProviders(props: ParentProps) {
return (
<MetaProvider>
@@ -86,80 +165,29 @@ function ServerKey(props: ParentProps) {
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
const platform = usePlatform()
const stored = (() => {
if (platform.platform !== "web") return
const result = platform.getDefaultServerUrl?.()
if (result instanceof Promise) return
if (!result) return
return normalizeServerUrl(result)
})()
const defaultServerUrl = () => {
if (props.defaultUrl) return props.defaultUrl
if (stored) return stored
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return window.location.origin
}
const storedDefaultServerUrl = getStoredDefaultServerUrl(platform)
const defaultServerUrl = resolveDefaultServerUrl({
defaultUrl: props.defaultUrl,
storedDefaultServerUrl,
hostname: location.hostname,
origin: window.location.origin,
isDev: import.meta.env.DEV,
devHost: import.meta.env.VITE_OPENCODE_SERVER_HOST,
devPort: import.meta.env.VITE_OPENCODE_SERVER_PORT,
})
return (
<ServerProvider defaultUrl={defaultServerUrl()} isSidecar={props.isSidecar}>
<ServerProvider defaultUrl={defaultServerUrl} isSidecar={props.isSidecar}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Router
root={(routerProps) => (
<SettingsProvider>
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
<ModelsProvider>
<CommandProvider>
<HighlightsProvider>
<Layout>
{props.children}
{routerProps.children}
</Layout>
</HighlightsProvider>
</CommandProvider>
</ModelsProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
</SettingsProvider>
)}
root={(routerProps) => <RouterRoot appChildren={props.children}>{routerProps.children}</RouterRoot>}
>
<Route
path="/"
component={() => (
<Suspense fallback={<Loading />}>
<Home />
</Suspense>
)}
/>
<Route path="/" component={HomeRoute} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id ?? "new"}>
<TerminalProvider>
<FileProvider>
<PromptProvider>
<CommentsProvider>
<Suspense fallback={<Loading />}>
<Session />
</Suspense>
</CommentsProvider>
</PromptProvider>
</FileProvider>
</TerminalProvider>
</Show>
)}
/>
<Route path="/" component={SessionIndexRoute} />
<Route path="/session/:id?" component={SessionRoute} />
</Route>
</Router>
</GlobalSyncProvider>
@@ -10,7 +10,6 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { iife } from "@opencode-ai/util/iife"
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link"
@@ -55,6 +54,47 @@ export function DialogConnectProvider(props: { provider: string }) {
error: undefined as string | undefined,
})
type Action =
| { type: "method.select"; index: number }
| { type: "method.reset" }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
| { type: "auth.error"; error: string }
function dispatch(action: Action) {
setStore(
produce((draft) => {
if (action.type === "method.select") {
draft.methodIndex = action.index
draft.authorization = undefined
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "method.reset") {
draft.methodIndex = undefined
draft.authorization = undefined
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "auth.pending") {
draft.state = "pending"
draft.error = undefined
return
}
if (action.type === "auth.complete") {
draft.state = "complete"
draft.authorization = action.authorization
draft.error = undefined
return
}
draft.state = "error"
draft.error = action.error
}),
)
}
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
const methodLabel = (value?: { type?: string; label?: string }) => {
@@ -63,6 +103,24 @@ export function DialogConnectProvider(props: { provider: string }) {
return value.label ?? ""
}
function formatError(value: unknown, fallback: string): string {
if (value && typeof value === "object" && "data" in value) {
const data = (value as { data?: { message?: unknown } }).data
if (typeof data?.message === "string" && data.message) return data.message
}
if (value && typeof value === "object" && "error" in value) {
const nested = formatError((value as { error?: unknown }).error, "")
if (nested) return nested
}
if (value && typeof value === "object" && "message" in value) {
const message = (value as { message?: unknown }).message
if (typeof message === "string" && message) return message
}
if (value instanceof Error && value.message) return value.message
if (typeof value === "string" && value) return value
return fallback
}
async function selectMethod(index: number) {
if (timer.current !== undefined) {
clearTimeout(timer.current)
@@ -70,17 +128,10 @@ export function DialogConnectProvider(props: { provider: string }) {
}
const method = methods()[index]
setStore(
produce((draft) => {
draft.methodIndex = index
draft.authorization = undefined
draft.state = undefined
draft.error = undefined
}),
)
dispatch({ type: "method.select", index })
if (method.type === "oauth") {
setStore("state", "pending")
dispatch({ type: "auth.pending" })
const start = Date.now()
await globalSDK.client.provider.oauth
.authorize(
@@ -100,18 +151,15 @@ export function DialogConnectProvider(props: { provider: string }) {
timer.current = setTimeout(() => {
timer.current = undefined
if (!alive.value) return
setStore("state", "complete")
setStore("authorization", x.data!)
dispatch({ type: "auth.complete", authorization: x.data! })
}, delay)
return
}
setStore("state", "complete")
setStore("authorization", x.data!)
dispatch({ type: "auth.complete", authorization: x.data! })
})
.catch((e) => {
if (!alive.value) return
setStore("state", "error")
setStore("error", String(e))
dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) })
})
}
}
@@ -129,10 +177,6 @@ export function DialogConnectProvider(props: { provider: string }) {
if (methods().length === 1) {
selectMethod(0)
}
document.addEventListener("keydown", handleKey)
onCleanup(() => {
document.removeEventListener("keydown", handleKey)
})
})
async function complete() {
@@ -152,17 +196,243 @@ export function DialogConnectProvider(props: { provider: string }) {
return
}
if (store.authorization) {
setStore("authorization", undefined)
setStore("methodIndex", undefined)
dispatch({ type: "method.reset" })
return
}
if (store.methodIndex) {
setStore("methodIndex", undefined)
if (store.methodIndex !== undefined) {
dispatch({ type: "method.reset" })
return
}
dialog.show(() => <DialogSelectProvider />)
}
function MethodSelection() {
return (
<>
<div class="text-14-regular text-text-base">
{language.t("provider.connect.selectMethod", { provider: provider().name })}
</div>
<div>
<List
ref={(ref) => {
listRef = ref
}}
items={methods}
key={(m) => m?.label}
onSelect={async (selected, index) => {
if (!selected) return
selectMethod(index)
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-2">
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
</div>
<span>{methodLabel(i)}</span>
</div>
)}
</List>
</div>
</>
)
}
function ApiAuthView() {
const [formStore, setFormStore] = createStore({
value: "",
error: undefined as string | undefined,
})
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const form = e.currentTarget as HTMLFormElement
const formData = new FormData(form)
const apiKey = formData.get("apiKey") as string
if (!apiKey?.trim()) {
setFormStore("error", language.t("provider.connect.apiKey.required"))
return
}
setFormStore("error", undefined)
await globalSDK.client.auth.set({
providerID: props.provider,
auth: {
type: "api",
key: apiKey,
},
})
await complete()
}
return (
<div class="flex flex-col gap-6">
<Switch>
<Match when={provider().id === "opencode"}>
<div class="flex flex-col gap-4">
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line1")}</div>
<div class="text-14-regular text-text-base">{language.t("provider.connect.opencodeZen.line2")}</div>
<div class="text-14-regular text-text-base">
{language.t("provider.connect.opencodeZen.visit.prefix")}
<Link href="https://opencode.ai/zen" tabIndex={-1}>
{language.t("provider.connect.opencodeZen.visit.link")}
</Link>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
</div>
</Match>
<Match when={true}>
<div class="text-14-regular text-text-base">
{language.t("provider.connect.apiKey.description", { provider: provider().name })}
</div>
</Match>
</Switch>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<TextField
autofocus
type="text"
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
placeholder={language.t("provider.connect.apiKey.placeholder")}
name="apiKey"
value={formStore.value}
onChange={(v) => setFormStore("value", v)}
validationState={formStore.error ? "invalid" : undefined}
error={formStore.error}
/>
<Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.submit")}
</Button>
</form>
</div>
)
}
function OAuthCodeView() {
const [formStore, setFormStore] = createStore({
value: "",
error: undefined as string | undefined,
})
onMount(() => {
if (store.authorization?.method === "code" && store.authorization?.url) {
platform.openLink(store.authorization.url)
}
})
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const form = e.currentTarget as HTMLFormElement
const formData = new FormData(form)
const code = formData.get("code") as string
if (!code?.trim()) {
setFormStore("error", language.t("provider.connect.oauth.code.required"))
return
}
setFormStore("error", undefined)
const result = await globalSDK.client.provider.oauth
.callback({
providerID: props.provider,
method: store.methodIndex,
code,
})
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (result.ok) {
await complete()
return
}
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
}
return (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.code.visit.prefix")}
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<TextField
autofocus
type="text"
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
placeholder={language.t("provider.connect.oauth.code.placeholder")}
name="code"
value={formStore.value}
onChange={(v) => setFormStore("value", v)}
validationState={formStore.error ? "invalid" : undefined}
error={formStore.error}
/>
<Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.submit")}
</Button>
</form>
</div>
)
}
function OAuthAutoView() {
const code = createMemo(() => {
const instructions = store.authorization?.instructions
if (instructions?.includes(":")) {
return instructions.split(":")[1]?.trim()
}
return instructions
})
onMount(() => {
void (async () => {
if (store.authorization?.url) {
platform.openLink(store.authorization.url)
}
const result = await globalSDK.client.provider.oauth
.callback({
providerID: props.provider,
method: store.methodIndex,
})
.then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (!alive.value) return
if (!result.ok) {
const message = formatError(result.error, language.t("common.requestFailed"))
dispatch({ type: "auth.error", error: message })
return
}
await complete()
})()
})
return (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")}
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
</div>
<TextField
label={language.t("provider.connect.oauth.auto.confirmationCode")}
class="font-mono"
value={code()}
readOnly
copyable
/>
<div class="text-14-regular text-text-base flex items-center gap-4">
<Spinner />
<span>{language.t("provider.connect.status.waiting")}</span>
</div>
</div>
)
}
return (
<Dialog
title={
@@ -188,267 +458,42 @@ export function DialogConnectProvider(props: { provider: string }) {
</div>
</div>
<div class="px-2.5 pb-10 flex flex-col gap-6">
<Switch>
<Match when={store.methodIndex === undefined}>
<div class="text-14-regular text-text-base">
{language.t("provider.connect.selectMethod", { provider: provider().name })}
</div>
<div class="">
<List
ref={(ref) => {
listRef = ref
}}
items={methods}
key={(m) => m?.label}
onSelect={async (method, index) => {
if (!method) return
selectMethod(index)
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-2">
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
<div class="w-2.5 h-0.5 ml-0 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
</div>
<span>{methodLabel(i)}</span>
</div>
)}
</List>
</div>
</Match>
<Match when={store.state === "pending"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={store.state === "error"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
</div>
</div>
</Match>
<Match when={method()?.type === "api"}>
{iife(() => {
const [formStore, setFormStore] = createStore({
value: "",
error: undefined as string | undefined,
})
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const form = e.currentTarget as HTMLFormElement
const formData = new FormData(form)
const apiKey = formData.get("apiKey") as string
if (!apiKey?.trim()) {
setFormStore("error", language.t("provider.connect.apiKey.required"))
return
}
setFormStore("error", undefined)
await globalSDK.client.auth.set({
providerID: props.provider,
auth: {
type: "api",
key: apiKey,
},
})
await complete()
}
return (
<div class="flex flex-col gap-6">
<Switch>
<Match when={provider().id === "opencode"}>
<div class="flex flex-col gap-4">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.opencodeZen.line1")}
</div>
<div class="text-14-regular text-text-base">
{language.t("provider.connect.opencodeZen.line2")}
</div>
<div class="text-14-regular text-text-base">
{language.t("provider.connect.opencodeZen.visit.prefix")}
<Link href="https://opencode.ai/zen" tabIndex={-1}>
{language.t("provider.connect.opencodeZen.visit.link")}
</Link>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
</div>
</Match>
<Match when={true}>
<div class="text-14-regular text-text-base">
{language.t("provider.connect.apiKey.description", { provider: provider().name })}
</div>
</Match>
</Switch>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<TextField
autofocus
type="text"
label={language.t("provider.connect.apiKey.label", { provider: provider().name })}
placeholder={language.t("provider.connect.apiKey.placeholder")}
name="apiKey"
value={formStore.value}
onChange={setFormStore.bind(null, "value")}
validationState={formStore.error ? "invalid" : undefined}
error={formStore.error}
/>
<Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.submit")}
</Button>
</form>
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
<Switch>
<Match when={store.methodIndex === undefined}>
<MethodSelection />
</Match>
<Match when={store.state === "pending"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
)
})}
</Match>
<Match when={method()?.type === "oauth"}>
<Switch>
<Match when={store.authorization?.method === "code"}>
{iife(() => {
const [formStore, setFormStore] = createStore({
value: "",
error: undefined as string | undefined,
})
onMount(() => {
if (store.authorization?.method === "code" && store.authorization?.url) {
platform.openLink(store.authorization.url)
}
})
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const form = e.currentTarget as HTMLFormElement
const formData = new FormData(form)
const code = formData.get("code") as string
if (!code?.trim()) {
setFormStore("error", language.t("provider.connect.oauth.code.required"))
return
}
setFormStore("error", undefined)
const result = await globalSDK.client.provider.oauth
.callback({
providerID: props.provider,
method: store.methodIndex,
code,
})
.then((value) =>
value.error ? { ok: false as const, error: value.error } : { ok: true as const },
)
.catch((error) => ({ ok: false as const, error }))
if (result.ok) {
await complete()
return
}
const message = result.error instanceof Error ? result.error.message : String(result.error)
setFormStore("error", message || language.t("provider.connect.oauth.code.invalid"))
}
return (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.code.visit.prefix")}
<Link href={store.authorization!.url}>
{language.t("provider.connect.oauth.code.visit.link")}
</Link>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<TextField
autofocus
type="text"
label={language.t("provider.connect.oauth.code.label", { method: method()?.label ?? "" })}
placeholder={language.t("provider.connect.oauth.code.placeholder")}
name="code"
value={formStore.value}
onChange={setFormStore.bind(null, "value")}
validationState={formStore.error ? "invalid" : undefined}
error={formStore.error}
/>
<Button class="w-auto" type="submit" size="large" variant="primary">
{language.t("common.submit")}
</Button>
</form>
</div>
)
})}
</Match>
<Match when={store.authorization?.method === "auto"}>
{iife(() => {
const code = createMemo(() => {
const instructions = store.authorization?.instructions
if (instructions?.includes(":")) {
return instructions?.split(":")[1]?.trim()
}
return instructions
})
onMount(() => {
void (async () => {
if (store.authorization?.url) {
platform.openLink(store.authorization.url)
}
const result = await globalSDK.client.provider.oauth
.callback({
providerID: props.provider,
method: store.methodIndex,
})
.then((value) =>
value.error ? { ok: false as const, error: value.error } : { ok: true as const },
)
.catch((error) => ({ ok: false as const, error }))
if (!alive.value) return
if (!result.ok) {
const message = result.error instanceof Error ? result.error.message : String(result.error)
setStore("state", "error")
setStore("error", message)
return
}
await complete()
})()
})
return (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")}
<Link href={store.authorization!.url}>
{language.t("provider.connect.oauth.auto.visit.link")}
</Link>
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
</div>
<TextField
label={language.t("provider.connect.oauth.auto.confirmationCode")}
class="font-mono"
value={code()}
readOnly
copyable
/>
<div class="text-14-regular text-text-base flex items-center gap-4">
<Spinner />
<span>{language.t("provider.connect.status.waiting")}</span>
</div>
</div>
)
})}
</Match>
</Switch>
</Match>
</Switch>
</div>
</Match>
<Match when={store.state === "error"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
</div>
</div>
</Match>
<Match when={method()?.type === "api"}>
<ApiAuthView />
</Match>
<Match when={method()?.type === "oauth"}>
<Switch>
<Match when={store.authorization?.method === "code"}>
<OAuthCodeView />
</Match>
<Match when={store.authorization?.method === "auto"}>
<OAuthAutoView />
</Match>
</Switch>
</Match>
</Switch>
</div>
</div>
</div>
</Dialog>
@@ -6,7 +6,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { For } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createStore } from "solid-js/store"
import { Link } from "@/components/link"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
@@ -16,6 +16,147 @@ import { DialogSelectProvider } from "./dialog-select-provider"
const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
type Translator = ReturnType<typeof useLanguage>["t"]
type ModelRow = {
id: string
name: string
}
type HeaderRow = {
key: string
value: string
}
type FormState = {
providerID: string
name: string
baseURL: string
apiKey: string
models: ModelRow[]
headers: HeaderRow[]
saving: boolean
}
type FormErrors = {
providerID: string | undefined
name: string | undefined
baseURL: string | undefined
models: Array<{ id?: string; name?: string }>
headers: Array<{ key?: string; value?: string }>
}
type ValidateArgs = {
form: FormState
t: Translator
disabledProviders: string[]
existingProviderIDs: Set<string>
}
function validateCustomProvider(input: ValidateArgs) {
const providerID = input.form.providerID.trim()
const name = input.form.name.trim()
const baseURL = input.form.baseURL.trim()
const apiKey = input.form.apiKey.trim()
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
const key = apiKey && !env ? apiKey : undefined
const idError = !providerID
? input.t("provider.custom.error.providerID.required")
: !PROVIDER_ID.test(providerID)
? input.t("provider.custom.error.providerID.format")
: undefined
const nameError = !name ? input.t("provider.custom.error.name.required") : undefined
const urlError = !baseURL
? input.t("provider.custom.error.baseURL.required")
: !/^https?:\/\//.test(baseURL)
? input.t("provider.custom.error.baseURL.format")
: undefined
const disabled = input.disabledProviders.includes(providerID)
const existsError = idError
? undefined
: input.existingProviderIDs.has(providerID) && !disabled
? input.t("provider.custom.error.providerID.exists")
: undefined
const seenModels = new Set<string>()
const modelErrors = input.form.models.map((m) => {
const id = m.id.trim()
const modelIdError = !id
? input.t("provider.custom.error.required")
: seenModels.has(id)
? input.t("provider.custom.error.duplicate")
: (() => {
seenModels.add(id)
return undefined
})()
const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined
return { id: modelIdError, name: modelNameError }
})
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
const seenHeaders = new Set<string>()
const headerErrors = input.form.headers.map((h) => {
const key = h.key.trim()
const value = h.value.trim()
if (!key && !value) return {}
const keyError = !key
? input.t("provider.custom.error.required")
: seenHeaders.has(key.toLowerCase())
? input.t("provider.custom.error.duplicate")
: (() => {
seenHeaders.add(key.toLowerCase())
return undefined
})()
const valueError = !value ? input.t("provider.custom.error.required") : undefined
return { key: keyError, value: valueError }
})
const headersValid = headerErrors.every((h) => !h.key && !h.value)
const headers = Object.fromEntries(
input.form.headers
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
.filter((h) => !!h.key && !!h.value)
.map((h) => [h.key, h.value]),
)
const errors: FormErrors = {
providerID: idError ?? existsError,
name: nameError,
baseURL: urlError,
models: modelErrors,
headers: headerErrors,
}
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
if (!ok) return { errors }
const options = {
baseURL,
...(Object.keys(headers).length ? { headers } : {}),
}
return {
errors,
result: {
providerID,
name,
key,
config: {
npm: OPENAI_COMPATIBLE,
name,
...(env ? { env: [env] } : {}),
options,
models,
},
},
}
}
type Props = {
back?: "providers" | "close"
}
@@ -26,7 +167,7 @@ export function DialogCustomProvider(props: Props) {
const globalSDK = useGlobalSDK()
const language = useLanguage()
const [form, setForm] = createStore({
const [form, setForm] = createStore<FormState>({
providerID: "",
name: "",
baseURL: "",
@@ -36,12 +177,12 @@ export function DialogCustomProvider(props: Props) {
saving: false,
})
const [errors, setErrors] = createStore({
providerID: undefined as string | undefined,
name: undefined as string | undefined,
baseURL: undefined as string | undefined,
models: [{} as { id?: string; name?: string }],
headers: [{} as { key?: string; value?: string }],
const [errors, setErrors] = createStore<FormErrors>({
providerID: undefined,
name: undefined,
baseURL: undefined,
models: [{}],
headers: [{}],
})
const goBack = () => {
@@ -53,169 +194,36 @@ export function DialogCustomProvider(props: Props) {
}
const addModel = () => {
setForm(
"models",
produce((draft) => {
draft.push({ id: "", name: "" })
}),
)
setErrors(
"models",
produce((draft) => {
draft.push({})
}),
)
setForm("models", (v) => [...v, { id: "", name: "" }])
setErrors("models", (v) => [...v, {}])
}
const removeModel = (index: number) => {
if (form.models.length <= 1) return
setForm(
"models",
produce((draft) => {
draft.splice(index, 1)
}),
)
setErrors(
"models",
produce((draft) => {
draft.splice(index, 1)
}),
)
setForm("models", (v) => v.filter((_, i) => i !== index))
setErrors("models", (v) => v.filter((_, i) => i !== index))
}
const addHeader = () => {
setForm(
"headers",
produce((draft) => {
draft.push({ key: "", value: "" })
}),
)
setErrors(
"headers",
produce((draft) => {
draft.push({})
}),
)
setForm("headers", (v) => [...v, { key: "", value: "" }])
setErrors("headers", (v) => [...v, {}])
}
const removeHeader = (index: number) => {
if (form.headers.length <= 1) return
setForm(
"headers",
produce((draft) => {
draft.splice(index, 1)
}),
)
setErrors(
"headers",
produce((draft) => {
draft.splice(index, 1)
}),
)
setForm("headers", (v) => v.filter((_, i) => i !== index))
setErrors("headers", (v) => v.filter((_, i) => i !== index))
}
const validate = () => {
const providerID = form.providerID.trim()
const name = form.name.trim()
const baseURL = form.baseURL.trim()
const apiKey = form.apiKey.trim()
const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
const key = apiKey && !env ? apiKey : undefined
const idError = !providerID
? language.t("provider.custom.error.providerID.required")
: !PROVIDER_ID.test(providerID)
? language.t("provider.custom.error.providerID.format")
: undefined
const nameError = !name ? language.t("provider.custom.error.name.required") : undefined
const urlError = !baseURL
? language.t("provider.custom.error.baseURL.required")
: !/^https?:\/\//.test(baseURL)
? language.t("provider.custom.error.baseURL.format")
: undefined
const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID)
const existingProvider = globalSync.data.provider.all.find((p) => p.id === providerID)
const existsError = idError
? undefined
: existingProvider && !disabled
? language.t("provider.custom.error.providerID.exists")
: undefined
const seenModels = new Set<string>()
const modelErrors = form.models.map((m) => {
const id = m.id.trim()
const modelIdError = !id
? language.t("provider.custom.error.required")
: seenModels.has(id)
? language.t("provider.custom.error.duplicate")
: (() => {
seenModels.add(id)
return undefined
})()
const modelNameError = !m.name.trim() ? language.t("provider.custom.error.required") : undefined
return { id: modelIdError, name: modelNameError }
const output = validateCustomProvider({
form,
t: language.t,
disabledProviders: globalSync.data.config.disabled_providers ?? [],
existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)),
})
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
const models = Object.fromEntries(form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
const seenHeaders = new Set<string>()
const headerErrors = form.headers.map((h) => {
const key = h.key.trim()
const value = h.value.trim()
if (!key && !value) return {}
const keyError = !key
? language.t("provider.custom.error.required")
: seenHeaders.has(key.toLowerCase())
? language.t("provider.custom.error.duplicate")
: (() => {
seenHeaders.add(key.toLowerCase())
return undefined
})()
const valueError = !value ? language.t("provider.custom.error.required") : undefined
return { key: keyError, value: valueError }
})
const headersValid = headerErrors.every((h) => !h.key && !h.value)
const headers = Object.fromEntries(
form.headers
.map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
.filter((h) => !!h.key && !!h.value)
.map((h) => [h.key, h.value]),
)
setErrors(
produce((draft) => {
draft.providerID = idError ?? existsError
draft.name = nameError
draft.baseURL = urlError
draft.models = modelErrors
draft.headers = headerErrors
}),
)
const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
if (!ok) return
const options = {
baseURL,
...(Object.keys(headers).length ? { headers } : {}),
}
return {
providerID,
name,
key,
config: {
npm: OPENAI_COMPATIBLE,
name,
...(env ? { env: [env] } : {}),
options,
models,
},
}
setErrors(output.errors)
return output.result
}
const save = async (e: SubmitEvent) => {
@@ -297,7 +305,7 @@ export function DialogCustomProvider(props: Props) {
placeholder={language.t("provider.custom.field.providerID.placeholder")}
description={language.t("provider.custom.field.providerID.description")}
value={form.providerID}
onChange={setForm.bind(null, "providerID")}
onChange={(v) => setForm("providerID", v)}
validationState={errors.providerID ? "invalid" : undefined}
error={errors.providerID}
/>
@@ -305,7 +313,7 @@ export function DialogCustomProvider(props: Props) {
label={language.t("provider.custom.field.name.label")}
placeholder={language.t("provider.custom.field.name.placeholder")}
value={form.name}
onChange={setForm.bind(null, "name")}
onChange={(v) => setForm("name", v)}
validationState={errors.name ? "invalid" : undefined}
error={errors.name}
/>
@@ -313,7 +321,7 @@ export function DialogCustomProvider(props: Props) {
label={language.t("provider.custom.field.baseURL.label")}
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
value={form.baseURL}
onChange={setForm.bind(null, "baseURL")}
onChange={(v) => setForm("baseURL", v)}
validationState={errors.baseURL ? "invalid" : undefined}
error={errors.baseURL}
/>
@@ -322,7 +330,7 @@ export function DialogCustomProvider(props: Props) {
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
description={language.t("provider.custom.field.apiKey.description")}
value={form.apiKey}
onChange={setForm.bind(null, "apiKey")}
onChange={(v) => setForm("apiKey", v)}
/>
</div>
@@ -33,6 +33,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
iconHover: false,
})
let iconInput: HTMLInputElement | undefined
function handleFileSelect(file: File) {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
@@ -72,31 +74,35 @@ export function DialogEditProject(props: { project: LocalProject }) {
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
setStore("saving", true)
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
await Promise.resolve()
.then(async () => {
setStore("saving", true)
const name = store.name.trim() === folderName() ? "" : store.name.trim()
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
await globalSDK.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color, override: store.iconUrl },
commands: { start },
if (props.project.id && props.project.id !== "global") {
await globalSDK.client.project.update({
projectID: props.project.id,
directory: props.project.worktree,
name,
icon: { color: store.color, override: store.iconUrl },
commands: { start },
})
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
dialog.close()
return
}
globalSync.project.meta(props.project.worktree, {
name,
icon: { color: store.color, override: store.iconUrl || undefined },
commands: { start: start || undefined },
})
dialog.close()
})
.finally(() => {
setStore("saving", false)
})
globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
setStore("saving", false)
dialog.close()
return
}
globalSync.project.meta(props.project.worktree, {
name,
icon: { color: store.color, override: store.iconUrl || undefined },
commands: { start: start || undefined },
})
setStore("saving", false)
dialog.close()
}
return (
@@ -134,7 +140,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
if (store.iconUrl && store.iconHover) {
clearIcon()
} else {
document.getElementById("icon-upload")?.click()
iconInput?.click()
}
}}
>
@@ -176,7 +182,16 @@ export function DialogEditProject(props: { project: LocalProject }) {
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
</div>
</div>
<input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
<input
id="icon-upload"
ref={(el) => {
iconInput = el
}}
type="file"
accept="image/*"
class="hidden"
onChange={handleInputChange}
/>
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak self-center">
<span>{language.t("dialog.project.edit.icon.hint")}</span>
<span>{language.t("dialog.project.edit.icon.recommended")}</span>
+17 -8
View File
@@ -6,6 +6,7 @@ import { usePrompt } from "@/context/prompt"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { showToast } from "@opencode-ai/ui/toast"
import { extractPromptFromParts } from "@/utils/prompt"
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/util/encode"
@@ -66,15 +67,23 @@ export const DialogFork: Component = () => {
attachmentName: language.t("common.attachment"),
})
dialog.close()
sdk.client.session.fork({ sessionID, messageID: item.id }).then((forked) => {
if (!forked.data) return
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
requestAnimationFrame(() => {
prompt.set(restored)
sdk.client.session
.fork({ sessionID, messageID: item.id })
.then((forked) => {
if (!forked.data) {
showToast({ title: language.t("common.requestFailed") })
return
}
dialog.close()
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
requestAnimationFrame(() => {
prompt.set(restored)
})
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
})
}
return (
@@ -1,6 +1,7 @@
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Button } from "@opencode-ai/ui/button"
import type { Component } from "solid-js"
import { useLocal } from "@/context/local"
@@ -17,6 +18,15 @@ export const DialogManageModels: Component = () => {
const handleConnectProvider = () => {
dialog.show(() => <DialogSelectProvider />)
}
const providerRank = (id: string) => popularProviders.indexOf(id)
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
const providerVisible = (providerID: string) =>
providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id }))
const setProviderVisibility = (providerID: string, checked: boolean) => {
providerList(providerID).forEach((x) => {
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked)
})
}
return (
<Dialog
@@ -35,21 +45,41 @@ export const DialogManageModels: Component = () => {
items={local.model.list()}
filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.name}
groupBy={(x) => x.provider.id}
groupHeader={(group) => {
const provider = group.items[0].provider
return (
<>
<span>{provider.name}</span>
<Tooltip
placement="top"
value={language.t("dialog.model.manage.provider.toggle", { provider: provider.name })}
>
<Switch
class="-mr-1"
checked={providerVisible(provider.id)}
onChange={(checked) => setProviderVisibility(provider.id, checked)}
hideLabel
>
{provider.name}
</Switch>
</Tooltip>
</>
)
}}
sortGroupsBy={(a, b) => {
const aProvider = a.items[0].provider.id
const bProvider = b.items[0].provider.id
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
const aRank = providerRank(a.items[0].provider.id)
const bRank = providerRank(b.items[0].provider.id)
const aPopular = aRank >= 0
const bPopular = bRank >= 0
if (aPopular && !bPopular) return -1
if (!aPopular && bPopular) return 1
return aRank - bRank
}}
onSelect={(x) => {
if (!x) return
const visible = local.model.visible({
modelID: x.id,
providerID: x.provider.id,
})
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible)
const key = { modelID: x.id, providerID: x.provider.id }
local.model.setVisibility(key, !local.model.visible(key))
}}
>
{(i) => (
@@ -57,12 +87,7 @@ export const DialogManageModels: Component = () => {
<span>{i.name}</span>
<div onClick={(e) => e.stopPropagation()}>
<Switch
checked={
!!local.model.visible({
modelID: i.id,
providerID: i.provider.id,
})
}
checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })}
onChange={(checked) => {
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
}}
@@ -1,4 +1,4 @@
import { createSignal, createEffect, onMount, onCleanup } from "solid-js"
import { createSignal } from "solid-js"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -40,8 +40,6 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
handleClose()
}
let focusTrap: HTMLDivElement | undefined
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
e.preventDefault()
@@ -60,27 +58,13 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {
}
}
onMount(() => {
focusTrap?.focus()
document.addEventListener("keydown", handleKeyDown)
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
})
// Refocus the trap when index changes to ensure escape always works
createEffect(() => {
index() // track index
focusTrap?.focus()
})
return (
<Dialog
size="large"
fit
class="w-[min(calc(100vw-40px),720px)] h-[min(calc(100vh-40px),400px)] -mt-20 min-h-0 overflow-hidden"
>
{/* Hidden element to capture initial focus and handle escape */}
<div ref={focusTrap} tabindex="0" class="absolute opacity-0 pointer-events-none" />
<div class="flex flex-1 min-w-0 min-h-0">
<div class="flex flex-1 min-w-0 min-h-0" tabIndex={0} autofocus onKeyDown={handleKeyDown}>
{/* Left side - Text content */}
<div class="flex flex-col flex-1 min-w-0 p-8">
{/* Top section - feature content (fixed position from top) */}
@@ -2,13 +2,13 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { List } from "@opencode-ai/ui/list"
import type { ListRef } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import fuzzysort from "fuzzysort"
import { createMemo, createResource, createSignal } from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import type { ListRef } from "@opencode-ai/ui/list"
interface DialogSelectDirectoryProps {
title?: string
@@ -21,157 +21,131 @@ type Row = {
search: string
}
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const sync = useGlobalSync()
const sdk = useGlobalSDK()
const dialog = useDialog()
const language = useLanguage()
function cleanInput(value: string) {
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
}
const [filter, setFilter] = createSignal("")
function normalizePath(input: string) {
const v = input.replaceAll("\\", "/")
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
return v.replace(/\/+/g, "/")
}
let list: ListRef | undefined
function normalizeDriveRoot(input: string) {
const v = normalizePath(input)
if (/^[A-Za-z]:$/.test(v)) return v + "/"
return v
}
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
function trimTrailing(input: string) {
const v = normalizeDriveRoot(input)
if (v === "/") return v
if (v === "//") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
return v.replace(/\/+$/, "")
}
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
async () => {
return sdk.client.path
.get()
.then((x) => x.data)
.catch(() => undefined)
},
{ initialValue: undefined },
)
function joinPath(base: string | undefined, rel: string) {
const b = trimTrailing(base ?? "")
const r = trimTrailing(rel).replace(/^\/+/, "")
if (!b) return r
if (!r) return b
if (b.endsWith("/")) return b + r
return b + "/" + r
}
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
function rootOf(input: string) {
const v = normalizeDriveRoot(input)
if (v.startsWith("//")) return "//"
if (v.startsWith("/")) return "/"
if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
return ""
}
const start = createMemo(
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
)
function parentOf(input: string) {
const v = trimTrailing(input)
if (v === "/") return v
if (v === "//") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
const i = v.lastIndexOf("/")
if (i <= 0) return "/"
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
return v.slice(0, i)
}
function modeOf(input: string) {
const raw = normalizeDriveRoot(input.trim())
if (!raw) return "relative" as const
if (raw.startsWith("~")) return "tilde" as const
if (rootOf(raw)) return "absolute" as const
return "relative" as const
}
function tildeOf(absolute: string, home: string) {
const full = trimTrailing(absolute)
if (!home) return ""
const hn = trimTrailing(home)
const lc = full.toLowerCase()
const hc = hn.toLowerCase()
if (lc === hc) return "~"
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
return ""
}
function displayPath(path: string, input: string, home: string) {
const full = trimTrailing(path)
if (modeOf(input) === "absolute") return full
return tildeOf(full, home) || full
}
function toRow(absolute: string, home: string): Row {
const full = trimTrailing(absolute)
const tilde = tildeOf(full, home)
const withSlash = (value: string) => {
if (!value) return ""
if (value.endsWith("/")) return value
return value + "/"
}
const search = Array.from(
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
).join("\n")
return { absolute: full, search }
}
function useDirectorySearch(args: {
sdk: ReturnType<typeof useGlobalSDK>
start: () => string | undefined
home: () => string
}) {
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
let current = 0
const clean = (value: string) => {
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
}
function normalize(input: string) {
const v = input.replaceAll("\\", "/")
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
return v.replace(/\/+/g, "/")
}
function normalizeDriveRoot(input: string) {
const v = normalize(input)
if (/^[A-Za-z]:$/.test(v)) return v + "/"
return v
}
function trimTrailing(input: string) {
const v = normalizeDriveRoot(input)
if (v === "/") return v
if (v === "//") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
return v.replace(/\/+$/, "")
}
function join(base: string | undefined, rel: string) {
const b = trimTrailing(base ?? "")
const r = trimTrailing(rel).replace(/^\/+/, "")
if (!b) return r
if (!r) return b
if (b.endsWith("/")) return b + r
return b + "/" + r
}
function rootOf(input: string) {
const v = normalizeDriveRoot(input)
if (v.startsWith("//")) return "//"
if (v.startsWith("/")) return "/"
if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3)
return ""
}
function parentOf(input: string) {
const v = trimTrailing(input)
if (v === "/") return v
if (v === "//") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
const i = v.lastIndexOf("/")
if (i <= 0) return "/"
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
return v.slice(0, i)
}
function modeOf(input: string) {
const raw = normalizeDriveRoot(input.trim())
if (!raw) return "relative" as const
if (raw.startsWith("~")) return "tilde" as const
if (rootOf(raw)) return "absolute" as const
return "relative" as const
}
function display(path: string, input: string) {
const full = trimTrailing(path)
if (modeOf(input) === "absolute") return full
return tildeOf(full) || full
}
function tildeOf(absolute: string) {
const full = trimTrailing(absolute)
const h = home()
if (!h) return ""
const hn = trimTrailing(h)
const lc = full.toLowerCase()
const hc = hn.toLowerCase()
if (lc === hc) return "~"
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
return ""
}
function row(absolute: string): Row {
const full = trimTrailing(absolute)
const tilde = tildeOf(full)
const withSlash = (value: string) => {
if (!value) return ""
if (value.endsWith("/")) return value
return value + "/"
}
const search = Array.from(
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
).join("\n")
return { absolute: full, search }
}
function scoped(value: string) {
const base = start()
const scoped = (value: string) => {
const base = args.start()
if (!base) return
const raw = normalizeDriveRoot(value)
if (!raw) return { directory: trimTrailing(base), path: "" }
const h = home()
if (raw === "~") return { directory: trimTrailing(h ?? base), path: "" }
if (raw.startsWith("~/")) return { directory: trimTrailing(h ?? base), path: raw.slice(2) }
const h = args.home()
if (raw === "~") return { directory: trimTrailing(h || base), path: "" }
if (raw.startsWith("~/")) return { directory: trimTrailing(h || base), path: raw.slice(2) }
const root = rootOf(raw)
if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) }
return { directory: trimTrailing(base), path: raw }
}
async function dirs(dir: string) {
const dirs = async (dir: string) => {
const key = trimTrailing(dir)
const existing = cache.get(key)
if (existing) return existing
const request = sdk.client.file
const request = args.sdk.client.file
.list({ directory: key, path: "" })
.then((x) => x.data ?? [])
.catch(() => [])
@@ -188,32 +162,34 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
return request
}
async function match(dir: string, query: string, limit: number) {
const match = async (dir: string, query: string, limit: number) => {
const items = await dirs(dir)
if (!query) return items.slice(0, limit).map((x) => x.absolute)
return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute)
}
const directories = async (filter: string) => {
const value = clean(filter)
return async (filter: string) => {
const token = ++current
const active = () => token === current
const value = cleanInput(filter)
const scopedInput = scoped(value)
if (!scopedInput) return [] as string[]
const raw = normalizeDriveRoot(value)
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
const query = normalizeDriveRoot(scopedInput.path)
const find = () =>
sdk.client.find
args.sdk.client.find
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
.then((x) => x.data ?? [])
.catch(() => [])
if (!isPath) {
const results = await find()
return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50)
if (!active()) return []
return results.map((rel) => joinPath(scopedInput.directory, rel)).slice(0, 50)
}
const segments = query.replace(/^\/+/, "").split("/")
@@ -224,17 +200,20 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const branch = 4
let paths = [scopedInput.directory]
for (const part of head) {
if (!active()) return []
if (part === "..") {
paths = paths.map(parentOf)
continue
}
const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat()
if (!active()) return []
paths = Array.from(new Set(next)).slice(0, cap)
if (paths.length === 0) return [] as string[]
}
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
if (!active()) return []
const deduped = Array.from(new Set(out))
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
const expand = !raw.endsWith("/")
@@ -249,13 +228,47 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
if (!target) return deduped.slice(0, 50)
const children = await match(target, "", 30)
if (!active()) return []
const items = Array.from(new Set([...deduped, ...children]))
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
}
}
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const sync = useGlobalSync()
const sdk = useGlobalSDK()
const dialog = useDialog()
const language = useLanguage()
const [filter, setFilter] = createSignal("")
let list: ListRef | undefined
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
async () => {
return sdk.client.path
.get()
.then((x) => x.data)
.catch(() => undefined)
},
{ initialValue: undefined },
)
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
const start = createMemo(
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
)
const directories = useDirectorySearch({
sdk,
home,
start,
})
const items = async (value: string) => {
const results = await directories(value)
return results.map(row)
return results.map((absolute) => toRow(absolute, home()))
}
function resolve(absolute: string) {
@@ -273,7 +286,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
key={(x) => x.absolute}
filterKeys={["search"]}
ref={(r) => (list = r)}
onFilter={(value) => setFilter(clean(value))}
onFilter={(value) => setFilter(cleanInput(value))}
onKeyEvent={(e, item) => {
if (e.key !== "Tab") return
if (e.shiftKey) return
@@ -282,7 +295,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
e.preventDefault()
e.stopPropagation()
const value = display(item.absolute, filter())
const value = displayPath(item.absolute, filter(), home())
list?.setFilter(value.endsWith("/") ? value : value + "/")
}}
onSelect={(path) => {
@@ -291,7 +304,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
}}
>
{(item) => {
const path = display(item.absolute, filter())
const path = displayPath(item.absolute, filter(), home())
if (path === "~") {
return (
<div class="w-full flex items-center justify-between rounded-md">
+229 -171
View File
@@ -36,6 +36,223 @@ type Entry = {
type DialogSelectFileMode = "all" | "files"
const ENTRY_LIMIT = 5
const COMMON_COMMAND_IDS = [
"session.new",
"workspace.new",
"session.previous",
"session.next",
"terminal.toggle",
"review.toggle",
] as const
const uniqueEntries = (items: Entry[]) => {
const seen = new Set<string>()
const out: Entry[] = []
for (const item of items) {
if (seen.has(item.id)) continue
seen.add(item.id)
out.push(item)
}
return out
}
const createCommandEntry = (option: CommandOption, category: string): Entry => ({
id: "command:" + option.id,
type: "command",
title: option.title,
description: option.description,
keybind: option.keybind,
category,
option,
})
const createFileEntry = (path: string, category: string): Entry => ({
id: "file:" + path,
type: "file",
title: path,
category,
path,
})
const createSessionEntry = (
input: {
directory: string
id: string
title: string
description: string
archived?: number
updated?: number
},
category: string,
): Entry => ({
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category,
directory: input.directory,
sessionID: input.id,
archived: input.archived,
updated: input.updated,
})
function createCommandEntries(props: {
filesOnly: () => boolean
command: ReturnType<typeof useCommand>
language: ReturnType<typeof useLanguage>
}) {
const allowed = createMemo(() => {
if (props.filesOnly()) return []
return props.command.options.filter(
(option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
})
const list = createMemo(() => {
const category = props.language.t("palette.group.commands")
return allowed().map((option) => createCommandEntry(option, category))
})
const picks = createMemo(() => {
const all = allowed()
const order = new Map<string, number>(COMMON_COMMAND_IDS.map((id, index) => [id, index]))
const picked = all.filter((option) => order.has(option.id))
const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
const category = props.language.t("palette.group.commands")
return sorted.map((option) => createCommandEntry(option, category))
})
return { allowed, list, picks }
}
function createFileEntries(props: {
file: ReturnType<typeof useFile>
tabs: () => ReturnType<ReturnType<typeof useLayout>["tabs"]>
language: ReturnType<typeof useLanguage>
}) {
const recent = createMemo(() => {
const all = props.tabs().all()
const active = props.tabs().active()
const order = active ? [active, ...all.filter((item) => item !== active)] : all
const seen = new Set<string>()
const category = props.language.t("palette.group.files")
const items: Entry[] = []
for (const item of order) {
const path = props.file.pathFromTab(item)
if (!path) continue
if (seen.has(path)) continue
seen.add(path)
items.push(createFileEntry(path, category))
}
return items.slice(0, ENTRY_LIMIT)
})
const root = createMemo(() => {
const category = props.language.t("palette.group.files")
const nodes = props.file.tree.children("")
const paths = nodes
.filter((node) => node.type === "file")
.map((node) => node.path)
.sort((a, b) => a.localeCompare(b))
return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category))
})
return { recent, root }
}
function createSessionEntries(props: {
workspaces: () => string[]
label: (directory: string) => string
globalSDK: ReturnType<typeof useGlobalSDK>
language: ReturnType<typeof useLanguage>
}) {
const state: {
token: number
inflight: Promise<Entry[]> | undefined
cached: Entry[] | undefined
} = {
token: 0,
inflight: undefined,
cached: undefined,
}
const sessions = (text: string) => {
const query = text.trim()
if (!query) {
state.token += 1
state.inflight = undefined
state.cached = undefined
return [] as Entry[]
}
if (state.cached) return state.cached
if (state.inflight) return state.inflight
const current = state.token
const dirs = props.workspaces()
if (dirs.length === 0) return [] as Entry[]
state.inflight = Promise.all(
dirs.map((directory) => {
const description = props.label(directory)
return props.globalSDK.client.session
.list({ directory, roots: true })
.then((x) =>
(x.data ?? [])
.filter((s) => !!s?.id)
.map((s) => ({
id: s.id,
title: s.title ?? props.language.t("command.session.new"),
description,
directory,
archived: s.time?.archived,
updated: s.time?.updated,
})),
)
.catch(
() =>
[] as {
id: string
title: string
description: string
directory: string
archived?: number
updated?: number
}[],
)
}),
)
.then((results) => {
if (state.token !== current) return [] as Entry[]
const seen = new Set<string>()
const category = props.language.t("command.category.session")
const next = results
.flat()
.filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map((item) => createSessionEntry(item, category))
state.cached = next
return next
})
.catch(() => [] as Entry[])
.finally(() => {
state.inflight = undefined
})
return state.inflight
}
return { sessions }
}
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
const command = useCommand()
const language = useLanguage()
@@ -52,40 +269,8 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const view = createMemo(() => layout.view(sessionKey))
const state = { cleanup: undefined as (() => void) | void, committed: false }
const [grouped, setGrouped] = createSignal(false)
const common = [
"session.new",
"workspace.new",
"session.previous",
"session.next",
"terminal.toggle",
"review.toggle",
]
const limit = 5
const allowed = createMemo(() => {
if (filesOnly()) return []
return command.options.filter(
(option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open",
)
})
const commandItem = (option: CommandOption): Entry => ({
id: "command:" + option.id,
type: "command",
title: option.title,
description: option.description,
keybind: option.keybind,
category: language.t("palette.group.commands"),
option,
})
const fileItem = (path: string): Entry => ({
id: "file:" + path,
type: "file",
title: path,
category: language.t("palette.group.files"),
path,
})
const commandEntries = createCommandEntries({ filesOnly, command, language })
const fileEntries = createFileEntries({ file, tabs, language })
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
@@ -116,136 +301,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
return `${kind} : ${name || path}`
}
const sessionItem = (input: {
directory: string
id: string
title: string
description: string
archived?: number
updated?: number
}): Entry => ({
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category: language.t("command.category.session"),
directory: input.directory,
sessionID: input.id,
archived: input.archived,
updated: input.updated,
})
const list = createMemo(() => allowed().map(commandItem))
const picks = createMemo(() => {
const all = allowed()
const order = new Map(common.map((id, index) => [id, index]))
const picked = all.filter((option) => order.has(option.id))
const base = picked.length ? picked : all.slice(0, limit)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
return sorted.map(commandItem)
})
const recent = createMemo(() => {
const all = tabs().all()
const active = tabs().active()
const order = active ? [active, ...all.filter((item) => item !== active)] : all
const seen = new Set<string>()
const items: Entry[] = []
for (const item of order) {
const path = file.pathFromTab(item)
if (!path) continue
if (seen.has(path)) continue
seen.add(path)
items.push(fileItem(path))
}
return items.slice(0, limit)
})
const root = createMemo(() => {
const nodes = file.tree.children("")
const paths = nodes
.filter((node) => node.type === "file")
.map((node) => node.path)
.sort((a, b) => a.localeCompare(b))
return paths.slice(0, limit).map(fileItem)
})
const unique = (items: Entry[]) => {
const seen = new Set<string>()
const out: Entry[] = []
for (const item of items) {
if (seen.has(item.id)) continue
seen.add(item.id)
out.push(item)
}
return out
}
const sessionToken = { value: 0 }
let sessionInflight: Promise<Entry[]> | undefined
let sessionAll: Entry[] | undefined
const sessions = (text: string) => {
const query = text.trim()
if (!query) {
sessionToken.value += 1
sessionInflight = undefined
sessionAll = undefined
return [] as Entry[]
}
if (sessionAll) return sessionAll
if (sessionInflight) return sessionInflight
const current = sessionToken.value
const dirs = workspaces()
if (dirs.length === 0) return [] as Entry[]
sessionInflight = Promise.all(
dirs.map((directory) => {
const description = label(directory)
return globalSDK.client.session
.list({ directory, roots: true })
.then((x) =>
(x.data ?? [])
.filter((s) => !!s?.id)
.map((s) => ({
id: s.id,
title: s.title ?? language.t("command.session.new"),
description,
directory,
archived: s.time?.archived,
updated: s.time?.updated,
})),
)
.catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[])
}),
)
.then((results) => {
if (sessionToken.value !== current) return [] as Entry[]
const seen = new Set<string>()
const next = results
.flat()
.filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map(sessionItem)
sessionAll = next
return next
})
.catch(() => [] as Entry[])
.finally(() => {
sessionInflight = undefined
})
return sessionInflight
}
const { sessions } = createSessionEntries({ workspaces, label, globalSDK, language })
const items = async (text: string) => {
const query = text.trim()
@@ -254,7 +310,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
if (!query && filesOnly()) {
const loaded = file.tree.state("")?.loaded
const pending = loaded ? Promise.resolve() : file.tree.list("")
const next = unique([...recent(), ...root()])
const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
if (loaded || next.length > 0) {
void pending
@@ -262,19 +318,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
}
await pending
return unique([...recent(), ...root()])
return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()])
}
if (!query) return [...picks(), ...recent()]
if (!query) return [...commandEntries.picks(), ...fileEntries.recent()]
if (filesOnly()) {
const files = await file.searchFiles(query)
return files.map(fileItem)
const category = language.t("palette.group.files")
return files.map((path) => createFileEntry(path, category))
}
const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
const entries = files.map(fileItem)
return [...list(), ...nextSessions, ...entries]
const category = language.t("palette.group.files")
const entries = files.map((path) => createFileEntry(path, category))
return [...commandEntries.list(), ...nextSessions, ...entries]
}
const handleMove = (item: Entry | undefined) => {
@@ -289,9 +347,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
tabs().open(value)
file.load(path)
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.open()
layout.fileTree.setTab("all")
props.onOpenFile?.(path)
tabs().setActive(value)
}
const handleSelect = (item: Entry | undefined) => {
@@ -6,6 +6,13 @@ import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/context/language"
const statusLabels = {
connected: "mcp.status.connected",
failed: "mcp.status.failed",
needs_auth: "mcp.status.needs_auth",
disabled: "mcp.status.disabled",
} as const
export const DialogSelectMcp: Component = () => {
const sync = useSync()
const sdk = useSDK()
@@ -21,15 +28,19 @@ export const DialogSelectMcp: Component = () => {
const toggle = async (name: string) => {
if (loading()) return
setLoading(name)
const status = sync.data.mcp[name]
if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name })
} else {
await sdk.client.mcp.connect({ name })
try {
const status = sync.data.mcp[name]
if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name })
} else {
await sdk.client.mcp.connect({ name })
}
const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data)
} finally {
setLoading(null)
}
const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data)
setLoading(null)
}
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
@@ -54,6 +65,11 @@ export const DialogSelectMcp: Component = () => {
{(i) => {
const mcpStatus = () => sync.data.mcp[i.name]
const status = () => mcpStatus()?.status
const statusLabel = () => {
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
if (!key) return
return language.t(key)
}
const error = () => {
const s = mcpStatus()
return s?.status === "failed" ? s.error : undefined
@@ -64,17 +80,8 @@ export const DialogSelectMcp: Component = () => {
<div class="flex flex-col gap-0.5 min-w-0">
<div class="flex items-center gap-2">
<span class="truncate">{i.name}</span>
<Show when={status() === "connected"}>
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.connected")}</span>
</Show>
<Show when={status() === "failed"}>
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.failed")}</span>
</Show>
<Show when={status() === "needs_auth"}>
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.needs_auth")}</span>
</Show>
<Show when={status() === "disabled"}>
<span class="text-11-regular text-text-weaker">{language.t("mcp.status.disabled")}</span>
<Show when={statusLabel()}>
<span class="text-11-regular text-text-weaker">{statusLabel()}</span>
</Show>
<Show when={loading() === i.name}>
<span class="text-11-regular text-text-weak">{language.t("common.loading.ellipsis")}</span>
@@ -6,7 +6,7 @@ import { List, type ListRef } from "@opencode-ai/ui/list"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Tag } from "@opencode-ai/ui/tag"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { type Component, onCleanup, onMount, Show } from "solid-js"
import { type Component, Show } from "solid-js"
import { useLocal } from "@/context/local"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { DialogConnectProvider } from "./dialog-connect-provider"
@@ -21,24 +21,17 @@ export const DialogSelectModelUnpaid: Component = () => {
const language = useLanguage()
let listRef: ListRef | undefined
const handleKey = (e: KeyboardEvent) => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") return
listRef?.onKeyDown(e)
}
onMount(() => {
document.addEventListener("keydown", handleKey)
onCleanup(() => {
document.removeEventListener("keydown", handleKey)
})
})
return (
<Dialog
title={language.t("dialog.model.select.title")}
class="overflow-y-auto [&_[data-slot=dialog-body]]:overflow-visible [&_[data-slot=dialog-body]]:flex-none"
>
<div class="flex flex-col gap-3 px-2.5">
<div class="flex flex-col gap-3 px-2.5" onKeyDown={handleKeyDown}>
<div class="text-14-medium text-text-base px-2.5">{language.t("dialog.model.unpaid.freeModels.title")}</div>
<List
class="[&_[data-slot=list-scroll]]:overflow-visible"
@@ -1,5 +1,5 @@
import { Popover as Kobalte } from "@kobalte/core/popover"
import { Component, ComponentProps, createEffect, createMemo, JSX, onCleanup, Show, ValidComponent } from "solid-js"
import { Component, ComponentProps, createMemo, JSX, Show, ValidComponent } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -15,6 +15,9 @@ import { DialogManageModels } from "./dialog-manage-models"
import { ModelTooltip } from "./model-tooltip"
import { useLanguage } from "@/context/language"
const isFree = (provider: string, cost: { input: number } | undefined) =>
provider === "opencode" && (!cost || cost.input === 0)
const ModelList: Component<{
provider?: string
class?: string
@@ -54,13 +57,7 @@ const ModelList: Component<{
class="w-full"
placement="right-start"
gutter={12}
value={
<ModelTooltip
model={item}
latest={item.latest}
free={item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)}
/>
}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
>
{node}
</Tooltip>
@@ -75,7 +72,7 @@ const ModelList: Component<{
{(i) => (
<div class="w-full flex items-center gap-x-2 text-13-regular">
<span class="truncate">{i.name}</span>
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
<Show when={isFree(i.provider.id, i.cost)}>
<Tag>{language.t("model.tag.free")}</Tag>
</Show>
<Show when={i.latest}>
@@ -98,13 +95,9 @@ export function ModelSelectorPopover(props: {
const [store, setStore] = createStore<{
open: boolean
dismiss: "escape" | "outside" | null
trigger?: HTMLElement
content?: HTMLElement
}>({
open: false,
dismiss: null,
trigger: undefined,
content: undefined,
})
const dialog = useDialog()
@@ -119,54 +112,6 @@ export function ModelSelectorPopover(props: {
}
const language = useLanguage()
createEffect(() => {
if (!store.open) return
const inside = (node: Node | null | undefined) => {
if (!node) return false
const el = store.content
if (el && el.contains(node)) return true
const anchor = store.trigger
if (anchor && anchor.contains(node)) return true
return false
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return
setStore("dismiss", "escape")
setStore("open", false)
event.preventDefault()
event.stopPropagation()
}
const onPointerDown = (event: PointerEvent) => {
const target = event.target
if (!(target instanceof Node)) return
if (inside(target)) return
setStore("dismiss", "outside")
setStore("open", false)
}
const onFocusIn = (event: FocusEvent) => {
if (!store.content) return
const target = event.target
if (!(target instanceof Node)) return
if (inside(target)) return
setStore("dismiss", "outside")
setStore("open", false)
}
window.addEventListener("keydown", onKeyDown, true)
window.addEventListener("pointerdown", onPointerDown, true)
window.addEventListener("focusin", onFocusIn, true)
onCleanup(() => {
window.removeEventListener("keydown", onKeyDown, true)
window.removeEventListener("pointerdown", onPointerDown, true)
window.removeEventListener("focusin", onFocusIn, true)
})
})
return (
<Kobalte
open={store.open}
@@ -178,12 +123,11 @@ export function ModelSelectorPopover(props: {
placement="top-start"
gutter={8}
>
<Kobalte.Trigger ref={(el) => setStore("trigger", el)} as={props.triggerAs ?? "div"} {...props.triggerProps}>
<Kobalte.Trigger as={props.triggerAs ?? "div"} {...props.triggerProps}>
{props.children}
</Kobalte.Trigger>
<Kobalte.Portal>
<Kobalte.Content
ref={(el) => setStore("content", el)}
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
onEscapeKeyDown={(event) => {
setStore("dismiss", "escape")
@@ -24,6 +24,12 @@ export const DialogSelectProvider: Component = () => {
const popularGroup = () => language.t("dialog.provider.group.popular")
const otherGroup = () => language.t("dialog.provider.group.other")
const customLabel = () => language.t("settings.providers.tag.custom")
const note = (id: string) => {
if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
if (id === "openai") return language.t("dialog.provider.openai.note")
if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
}
return (
<Dialog title={language.t("command.provider.connect")} transition>
@@ -34,7 +40,7 @@ export const DialogSelectProvider: Component = () => {
key={(x) => x?.id}
items={() => {
language.locale()
return [{ id: CUSTOM_ID, name: "Custom provider" }, ...providers.all()]
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all()]
}}
filterKeys={["id", "name"]}
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
@@ -70,15 +76,7 @@ export const DialogSelectProvider: Component = () => {
<Show when={i.id === "opencode"}>
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={i.id === "anthropic"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.anthropic.note")}</div>
</Show>
<Show when={i.id === "openai"}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.openai.note")}</div>
</Show>
<Show when={i.id.startsWith("github-copilot")}>
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.copilot.note")}</div>
</Show>
<Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
</div>
)}
</List>
@@ -38,6 +38,64 @@ interface EditRowProps {
onBlur: () => void
}
function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
function useDefaultServer(platform: ReturnType<typeof usePlatform>, language: ReturnType<typeof useLanguage>) {
const [defaultUrl, defaultUrlActions] = createResource(
async () => {
try {
const url = await platform.getDefaultServerUrl?.()
if (!url) return null
return normalizeServerUrl(url) ?? null
} catch (err) {
showRequestError(language, err)
return null
}
},
{ initialValue: null },
)
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
const setDefault = async (url: string | null) => {
try {
await platform.setDefaultServerUrl?.(url)
defaultUrlActions.mutate(url)
} catch (err) {
showRequestError(language, err)
}
}
return { defaultUrl, canDefault, setDefault }
}
function useServerPreview(fetcher: typeof fetch) {
const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) return false
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
if (!host) return false
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
return host.includes(".") || host.includes(":")
}
const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
setStatus(undefined)
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
const result = await checkServerHealth(normalized, fetcher)
setStatus(result.healthy)
}
return { previewStatus }
}
function AddRow(props: AddRowProps) {
return (
<div class="flex items-center px-4 min-h-14 py-3 min-w-0 flex-1">
@@ -115,6 +173,10 @@ export function DialogSelectServer() {
const platform = usePlatform()
const globalSDK = useGlobalSDK()
const language = useLanguage()
const fetcher = platform.fetch ?? globalThis.fetch
const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language)
const { previewStatus } = useServerPreview(fetcher)
let listRoot: HTMLDivElement | undefined
const [store, setStore] = createStore({
status: {} as Record<string, ServerHealth | undefined>,
addServer: {
@@ -132,43 +194,6 @@ export function DialogSelectServer() {
status: undefined as boolean | undefined,
},
})
const [defaultUrl, defaultUrlActions] = createResource(
async () => {
try {
const url = await platform.getDefaultServerUrl?.()
if (!url) return null
return normalizeServerUrl(url) ?? null
} catch (err) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
return null
}
},
{ initialValue: null },
)
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
const fetcher = platform.fetch ?? globalThis.fetch
const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
if (!normalized) return false
const host = normalized.replace(/^https?:\/\//, "").split("/")[0]
if (!host) return false
if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true
return host.includes(".") || host.includes(":")
}
const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => {
setStatus(undefined)
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
const result = await checkServerHealth(normalized, fetcher)
setStatus(result.healthy)
}
const resetAdd = () => {
setStore("addServer", {
@@ -263,7 +288,7 @@ export function DialogSelectServer() {
}
const scrollListToBottom = () => {
const scroll = document.querySelector<HTMLDivElement>('[data-component="list"] [data-slot="list-scroll"]')
const scroll = listRoot?.querySelector<HTMLDivElement>('[data-slot="list-scroll"]')
if (!scroll) return
requestAnimationFrame(() => {
scroll.scrollTop = scroll.scrollHeight
@@ -363,158 +388,134 @@ export function DialogSelectServer() {
return (
<Dialog title={language.t("dialog.server.title")}>
<div class="flex flex-col gap-2">
<List
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={sortedItems}
key={(x) => x}
onSelect={(x) => {
if (x) select(x)
}}
onFilter={(value) => {
if (value && store.addServer.showForm && !store.addServer.adding) {
resetAdd()
}
}}
divider={true}
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
add={
store.addServer.showForm
? {
render: () => (
<AddRow
value={store.addServer.url}
placeholder={language.t("dialog.server.add.placeholder")}
adding={store.addServer.adding}
error={store.addServer.error}
status={store.addServer.status}
onChange={handleAddChange}
onKeyDown={handleAddKey}
onBlur={blurAdd}
/>
),
}
: undefined
}
>
{(i) => {
return (
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
<Show
when={store.editServer.id !== i}
fallback={
<EditRow
value={store.editServer.value}
placeholder={language.t("dialog.server.add.placeholder")}
busy={store.editServer.busy}
error={store.editServer.error}
status={store.editServer.status}
onChange={handleEditChange}
onKeyDown={(event) => handleEditKey(event, i)}
onBlur={() => handleEdit(i, store.editServer.value)}
/>
}
>
<ServerRow
url={i}
status={store.status[i]}
dimmed={store.status[i]?.healthy === false}
class="flex items-center gap-3 px-4 min-w-0 flex-1"
badge={
<Show when={defaultUrl() === i}>
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
}
/>
</Show>
<Show when={store.editServer.id !== i}>
<div class="flex items-center justify-center gap-5 pl-4">
<Show when={current() === i}>
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
</Show>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
<div ref={(el) => (listRoot = el)}>
<List
search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }}
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={sortedItems}
key={(x) => x}
onSelect={(x) => {
if (x) select(x)
}}
onFilter={(value) => {
if (value && store.addServer.showForm && !store.addServer.adding) {
resetAdd()
}
}}
divider={true}
class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0"
add={
store.addServer.showForm
? {
render: () => (
<AddRow
value={store.addServer.url}
placeholder={language.t("dialog.server.add.placeholder")}
adding={store.addServer.adding}
error={store.addServer.error}
status={store.addServer.status}
onChange={handleAddChange}
onKeyDown={handleAddKey}
onBlur={blurAdd}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
setStore("editServer", {
id: i,
value: i,
error: "",
status: store.status[i]?.healthy,
})
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultUrl() !== i}>
),
}
: undefined
}
>
{(i) => {
return (
<div class="flex items-center gap-3 min-w-0 flex-1 group/item">
<Show
when={store.editServer.id !== i}
fallback={
<EditRow
value={store.editServer.value}
placeholder={language.t("dialog.server.add.placeholder")}
busy={store.editServer.busy}
error={store.editServer.error}
status={store.editServer.status}
onChange={handleEditChange}
onKeyDown={(event) => handleEditKey(event, i)}
onBlur={() => handleEdit(i, store.editServer.value)}
/>
}
>
<ServerRow
url={i}
status={store.status[i]}
dimmed={store.status[i]?.healthy === false}
class="flex items-center gap-3 px-4 min-w-0 flex-1"
badge={
<Show when={defaultUrl() === i}>
<span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
}
/>
</Show>
<Show when={store.editServer.id !== i}>
<div class="flex items-center justify-center gap-5 pl-4">
<Show when={current() === i}>
<p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p>
</Show>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={async () => {
try {
await platform.setDefaultServerUrl?.(i)
defaultUrlActions.mutate(i)
} catch (err) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
onSelect={() => {
setStore("editServer", {
id: i,
value: i,
error: "",
status: store.status[i]?.healthy,
})
}}
>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultUrl() === i}>
<Show when={canDefault() && defaultUrl() !== i}>
<DropdownMenu.Item onSelect={() => setDefault(i)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultUrl() === i}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={async () => {
try {
await platform.setDefaultServerUrl?.(null)
defaultUrlActions.mutate(null)
} catch (err) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
}}
onSelect={() => handleRemove(i)}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(i)}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</Show>
</div>
)
}}
</List>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</Show>
</div>
)
}}
</List>
</div>
<div class="px-5 pb-5">
<Button
@@ -67,15 +67,6 @@ export const DialogSettings: Component = () => {
<Tabs.Content value="models" class="no-scrollbar">
<SettingsModels />
</Tabs.Content>
{/* <Tabs.Content value="agents" class="no-scrollbar"> */}
{/* <SettingsAgents /> */}
{/* </Tabs.Content> */}
{/* <Tabs.Content value="commands" class="no-scrollbar"> */}
{/* <SettingsCommands /> */}
{/* </Tabs.Content> */}
{/* <Tabs.Content value="mcp" class="no-scrollbar"> */}
{/* <SettingsMcp /> */}
{/* </Tabs.Content> */}
</Tabs>
</Dialog>
)
+212 -174
View File
@@ -15,6 +15,7 @@ import {
Switch,
untrack,
type ComponentProps,
type JSXElement,
type ParentProps,
} from "solid-js"
import { Dynamic } from "solid-js/web"
@@ -59,6 +60,189 @@ export function dirsToExpand(input: {
return [...input.filter.dirs].filter((dir) => !input.expanded(dir))
}
const kindLabel = (kind: Kind) => {
if (kind === "add") return "A"
if (kind === "del") return "D"
return "M"
}
const kindTextColor = (kind: Kind) => {
if (kind === "add") return "color: var(--icon-diff-add-base)"
if (kind === "del") return "color: var(--icon-diff-delete-base)"
return "color: var(--icon-warning-active)"
}
const kindDotColor = (kind: Kind) => {
if (kind === "add") return "background-color: var(--icon-diff-add-base)"
if (kind === "del") return "background-color: var(--icon-diff-delete-base)"
return "background-color: var(--icon-warning-active)"
}
const visibleKind = (node: FileNode, kinds?: ReadonlyMap<string, Kind>, marks?: Set<string>) => {
const kind = kinds?.get(node.path)
if (!kind) return
if (!marks?.has(node.path)) return
return kind
}
const buildDragImage = (target: HTMLElement) => {
const icon = target.querySelector('[data-component="file-icon"]') ?? target.querySelector("svg")
const text = target.querySelector("span")
if (!icon || !text) return
const image = document.createElement("div")
image.className =
"flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong"
image.style.position = "absolute"
image.style.top = "-1000px"
image.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML
return image
}
const withFileDragImage = (event: DragEvent) => {
const image = buildDragImage(event.currentTarget as HTMLElement)
if (!image) return
document.body.appendChild(image)
event.dataTransfer?.setDragImage(image, 0, 12)
setTimeout(() => document.body.removeChild(image), 0)
}
const FileTreeNode = (
p: ParentProps &
ComponentProps<"div"> &
ComponentProps<"button"> & {
node: FileNode
level: number
active?: string
nodeClass?: string
draggable: boolean
kinds?: ReadonlyMap<string, Kind>
marks?: Set<string>
as?: "div" | "button"
},
) => {
const [local, rest] = splitProps(p, [
"node",
"level",
"active",
"nodeClass",
"draggable",
"kinds",
"marks",
"as",
"children",
"class",
"classList",
])
const kind = () => visibleKind(local.node, local.kinds, local.marks)
const active = () => !!kind() && !local.node.ignored
const color = () => {
const value = kind()
if (!value) return
return kindTextColor(value)
}
return (
<Dynamic
component={local.as ?? "div"}
classList={{
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
"bg-surface-base-active": local.node.path === local.active,
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
[local.nodeClass ?? ""]: !!local.nodeClass,
}}
style={`padding-left: ${Math.max(0, 8 + local.level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
draggable={local.draggable}
onDragStart={(event: DragEvent) => {
if (!local.draggable) return
event.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy"
withFileDragImage(event)
}}
{...rest}
>
{local.children}
<span
classList={{
"flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true,
"text-text-weaker": local.node.ignored,
"text-text-weak": !local.node.ignored && !active(),
}}
style={active() ? color() : undefined}
>
{local.node.name}
</span>
{(() => {
const value = kind()
if (!value) return null
if (local.node.type === "file") {
return (
<span class="shrink-0 w-4 text-center text-12-medium" style={kindTextColor(value)}>
{kindLabel(value)}
</span>
)
}
return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={kindDotColor(value)} />
})()}
</Dynamic>
)
}
const FileTreeNodeTooltip = (props: { enabled: boolean; node: FileNode; kind?: Kind; children: JSXElement }) => {
if (!props.enabled) return props.children
const parts = props.node.path.split("/")
const leaf = parts[parts.length - 1] ?? props.node.path
const head = parts.slice(0, -1).join("/")
const prefix = head ? `${head}/` : ""
const label =
props.kind === "add"
? "Additions"
: props.kind === "del"
? "Deletions"
: props.kind === "mix"
? "Modifications"
: undefined
return (
<Tooltip
openDelay={2000}
placement="bottom-start"
class="w-full"
contentStyle={{ "max-width": "480px", width: "fit-content" }}
value={
<div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
<span
class="min-w-0 truncate text-text-invert-base"
style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
>
{prefix}
</span>
<span class="shrink-0 text-text-invert-strong">{leaf}</span>
<Show when={label}>
{(text) => (
<>
<span class="mx-1 font-bold text-text-invert-strong"></span>
<span class="shrink-0 text-text-invert-strong">{text()}</span>
</>
)}
</Show>
<Show when={props.node.type === "directory" && props.node.ignored}>
<>
<span class="mx-1 font-bold text-text-invert-strong"></span>
<span class="shrink-0 text-text-invert-strong">Ignored</span>
</>
</Show>
</div>
}
>
{props.children}
</Tooltip>
)
}
export default function FileTree(props: {
path: string
class?: string
@@ -230,178 +414,13 @@ export default function FileTree(props: {
return out
})
const Node = (
p: ParentProps &
ComponentProps<"div"> &
ComponentProps<"button"> & {
node: FileNode
as?: "div" | "button"
},
) => {
const [local, rest] = splitProps(p, ["node", "as", "children", "class", "classList"])
return (
<Dynamic
component={local.as ?? "div"}
classList={{
"w-full min-w-0 h-6 flex items-center justify-start gap-x-1.5 rounded-md px-1.5 py-0 text-left hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
"bg-surface-base-active": local.node.path === props.active,
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
[props.nodeClass ?? ""]: !!props.nodeClass,
}}
style={`padding-left: ${Math.max(0, 8 + level * 12 - (local.node.type === "file" ? 24 : 4))}px`}
draggable={draggable()}
onDragStart={(e: DragEvent) => {
if (!draggable()) return
e.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
e.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"
const dragImage = document.createElement("div")
dragImage.className =
"flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong"
dragImage.style.position = "absolute"
dragImage.style.top = "-1000px"
const icon =
(e.currentTarget as HTMLElement).querySelector('[data-component="file-icon"]') ??
(e.currentTarget as HTMLElement).querySelector("svg")
const text = (e.currentTarget as HTMLElement).querySelector("span")
if (icon && text) {
dragImage.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML
}
document.body.appendChild(dragImage)
e.dataTransfer?.setDragImage(dragImage, 0, 12)
setTimeout(() => document.body.removeChild(dragImage), 0)
}}
{...rest}
>
{local.children}
{(() => {
const kind = kinds()?.get(local.node.path)
const marked = marks()?.has(local.node.path) ?? false
const active = !!kind && marked && !local.node.ignored
const color =
kind === "add"
? "color: var(--icon-diff-add-base)"
: kind === "del"
? "color: var(--icon-diff-delete-base)"
: kind === "mix"
? "color: var(--icon-warning-active)"
: undefined
return (
<span
classList={{
"flex-1 min-w-0 text-12-medium whitespace-nowrap truncate": true,
"text-text-weaker": local.node.ignored,
"text-text-weak": !local.node.ignored && !active,
}}
style={active ? color : undefined}
>
{local.node.name}
</span>
)
})()}
{(() => {
const kind = kinds()?.get(local.node.path)
if (!kind) return null
if (!marks()?.has(local.node.path)) return null
if (local.node.type === "file") {
const text = kind === "add" ? "A" : kind === "del" ? "D" : "M"
const color =
kind === "add"
? "color: var(--icon-diff-add-base)"
: kind === "del"
? "color: var(--icon-diff-delete-base)"
: "color: var(--icon-warning-active)"
return (
<span class="shrink-0 w-4 text-center text-12-medium" style={color}>
{text}
</span>
)
}
if (local.node.type === "directory") {
const color =
kind === "add"
? "background-color: var(--icon-diff-add-base)"
: kind === "del"
? "background-color: var(--icon-diff-delete-base)"
: "background-color: var(--icon-warning-active)"
return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={color} />
}
return null
})()}
</Dynamic>
)
}
return (
<div class={`flex flex-col gap-0.5 ${props.class ?? ""}`}>
<For each={nodes()}>
{(node) => {
const expanded = () => file.tree.state(node.path)?.expanded ?? false
const deep = () => deeps().get(node.path) ?? -1
const Wrapper = (p: ParentProps) => {
if (!tooltip()) return p.children
const parts = node.path.split("/")
const leaf = parts[parts.length - 1] ?? node.path
const head = parts.slice(0, -1).join("/")
const prefix = head ? `${head}/` : ""
const kind = () => kinds()?.get(node.path)
const label = () => {
const k = kind()
if (!k) return
if (k === "add") return "Additions"
if (k === "del") return "Deletions"
return "Modifications"
}
const ignored = () => node.type === "directory" && node.ignored
return (
<Tooltip
openDelay={2000}
placement="bottom-start"
class="w-full"
contentStyle={{ "max-width": "480px", width: "fit-content" }}
value={
<div class="flex items-center min-w-0 whitespace-nowrap text-12-regular">
<span
class="min-w-0 truncate text-text-invert-base"
style={{ direction: "rtl", "unicode-bidi": "plaintext" }}
>
{prefix}
</span>
<span class="shrink-0 text-text-invert-strong">{leaf}</span>
<Show when={label()}>
{(t: () => string) => (
<>
<span class="mx-1 font-bold text-text-invert-strong"></span>
<span class="shrink-0 text-text-invert-strong">{t()}</span>
</>
)}
</Show>
<Show when={ignored()}>
<>
<span class="mx-1 font-bold text-text-invert-strong"></span>
<span class="shrink-0 text-text-invert-strong">Ignored</span>
</>
</Show>
</div>
}
>
{p.children}
</Tooltip>
)
}
const kind = () => visibleKind(node, kinds(), marks())
return (
<Switch>
@@ -415,13 +434,21 @@ export default function FileTree(props: {
onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))}
>
<Collapsible.Trigger>
<Wrapper>
<Node node={node}>
<FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
<FileTreeNode
node={node}
level={level}
active={props.active}
nodeClass={props.nodeClass}
draggable={draggable()}
kinds={kinds()}
marks={marks()}
>
<div class="size-4 flex items-center justify-center text-icon-weak">
<Icon name={expanded() ? "chevron-down" : "chevron-right"} size="small" />
</div>
</Node>
</Wrapper>
</FileTreeNode>
</FileTreeNodeTooltip>
</Collapsible.Trigger>
<Collapsible.Content class="relative pt-0.5">
<div
@@ -451,12 +478,23 @@ export default function FileTree(props: {
</Collapsible>
</Match>
<Match when={node.type === "file"}>
<Wrapper>
<Node node={node} as="button" type="button" onClick={() => props.onFileClick?.(node)}>
<FileTreeNodeTooltip enabled={tooltip()} node={node} kind={kind()}>
<FileTreeNode
node={node}
level={level}
active={props.active}
nodeClass={props.nodeClass}
draggable={draggable()}
kinds={kinds()}
marks={marks()}
as="button"
type="button"
onClick={() => props.onFileClick?.(node)}
>
<div class="w-4 shrink-0" />
<FileIcon node={node} class="text-icon-weak size-4" />
</Node>
</Wrapper>
</FileTreeNode>
</FileTreeNodeTooltip>
</Match>
</Switch>
)
+13 -4
View File
@@ -1,17 +1,26 @@
import { ComponentProps, splitProps } from "solid-js"
import { usePlatform } from "@/context/platform"
export interface LinkProps extends ComponentProps<"button"> {
export interface LinkProps extends Omit<ComponentProps<"a">, "href"> {
href: string
}
export function Link(props: LinkProps) {
const platform = usePlatform()
const [local, rest] = splitProps(props, ["href", "children"])
const [local, rest] = splitProps(props, ["href", "children", "class"])
return (
<button class="text-text-strong underline" onClick={() => platform.openLink(local.href)} {...rest}>
<a
href={local.href}
class={`text-text-strong underline ${local.class ?? ""}`}
onClick={(event) => {
if (!local.href) return
event.preventDefault()
platform.openLink(local.href)
}}
{...rest}
>
{local.children}
</button>
</a>
)
}
+87 -63
View File
@@ -38,7 +38,12 @@ import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom"
import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments"
import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history"
import {
canNavigateHistoryAtCursor,
navigatePromptHistory,
prependHistoryEntry,
promptLength,
} from "./prompt-input/history"
import { createPromptSubmit } from "./prompt-input/submit"
import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
import { PromptContextItems } from "./prompt-input/context-items"
@@ -158,14 +163,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
if (wantsReview) {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.open()
layout.fileTree.setTab("changes")
tabs().setActive("review")
requestAnimationFrame(() => comments.setFocus(focus))
return
}
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.open()
layout.fileTree.setTab("all")
const tab = files.tab(item.path)
tabs().open(tab)
@@ -277,6 +281,47 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isFocused = createFocusSignal(() => editorRef)
const closePopover = () => setStore("popover", null)
const resetHistoryNavigation = (force = false) => {
if (!force && (store.historyIndex < 0 || store.applyingHistory)) return
setStore("historyIndex", -1)
setStore("savedPrompt", null)
}
const clearEditor = () => {
editorRef.innerHTML = ""
}
const setEditorText = (text: string) => {
clearEditor()
editorRef.textContent = text
}
const focusEditorEnd = () => {
requestAnimationFrame(() => {
editorRef.focus()
const range = document.createRange()
const selection = window.getSelection()
range.selectNodeContents(editorRef)
range.collapse(false)
selection?.removeAllRanges()
selection?.addRange(range)
})
}
const currentCursor = () => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) return null
return getCursorPosition(editorRef)
}
const renderEditorWithCursor = (parts: Prompt) => {
const cursor = currentCursor()
renderEditor(parts)
if (cursor !== null) setCursorPosition(editorRef, cursor)
}
createEffect(() => {
params.id
if (params.id) return
@@ -290,7 +335,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
createEffect(() => {
if (!isFocused()) setStore("popover", null)
if (!isFocused()) closePopover()
})
// Safety: reset composing state on focus change to prevent stuck state
@@ -304,6 +349,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
.filter((agent) => !agent.hidden && agent.mode !== "primary")
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
)
const agentNames = createMemo(() => local.agent.list().map((agent) => agent.name))
const handleAtSelect = (option: AtOption | undefined) => {
if (!option) return
@@ -381,26 +427,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const handleSlashSelect = (cmd: SlashCommand | undefined) => {
if (!cmd) return
setStore("popover", null)
closePopover()
if (cmd.type === "custom") {
const text = `/${cmd.trigger} `
editorRef.innerHTML = ""
editorRef.textContent = text
setEditorText(text)
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
requestAnimationFrame(() => {
editorRef.focus()
const range = document.createRange()
const sel = window.getSelection()
range.selectNodeContents(editorRef)
range.collapse(false)
sel?.removeAllRanges()
sel?.addRange(range)
})
focusEditorEnd()
return
}
editorRef.innerHTML = ""
clearEditor()
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
command.trigger(cmd.id, "slash")
}
@@ -441,10 +478,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const prev = node.previousSibling
const next = node.nextSibling
const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR"
const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR"
if (!prevIsBr && !nextIsBr) return false
if (nextIsBr && !prevIsBr && prev) return false
return true
return !!prevIsBr && !next
}
if (node.nodeType !== Node.ELEMENT_NODE) return false
const el = node as HTMLElement
@@ -454,7 +488,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
const renderEditor = (parts: Prompt) => {
editorRef.innerHTML = ""
clearEditor()
for (const part of parts) {
if (part.type === "text") {
editorRef.appendChild(createTextFragment(part.content))
@@ -464,6 +498,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
editorRef.appendChild(createPill(part))
}
}
const last = editorRef.lastChild
if (last?.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR") {
editorRef.appendChild(document.createTextNode("\u200B"))
}
}
createEffect(
@@ -514,34 +553,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
mirror.input = false
if (isNormalizedEditor()) return
const selection = window.getSelection()
let cursorPosition: number | null = null
if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) {
cursorPosition = getCursorPosition(editorRef)
}
renderEditor(inputParts)
if (cursorPosition !== null) {
setCursorPosition(editorRef, cursorPosition)
}
renderEditorWithCursor(inputParts)
return
}
const domParts = parseFromDOM()
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
const selection = window.getSelection()
let cursorPosition: number | null = null
if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) {
cursorPosition = getCursorPosition(editorRef)
}
renderEditor(inputParts)
if (cursorPosition !== null) {
setCursorPosition(editorRef, cursorPosition)
}
renderEditorWithCursor(inputParts)
},
),
)
@@ -636,11 +655,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
if (shouldReset) {
setStore("popover", null)
if (store.historyIndex >= 0 && !store.applyingHistory) {
setStore("historyIndex", -1)
setStore("savedPrompt", null)
}
closePopover()
resetHistoryNavigation()
if (prompt.dirty()) {
mirror.input = true
prompt.set(DEFAULT_PROMPT, 0)
@@ -662,16 +678,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
slashOnInput(slashMatch[1])
setStore("popover", "slash")
} else {
setStore("popover", null)
closePopover()
}
} else {
setStore("popover", null)
closePopover()
}
if (store.historyIndex >= 0 && !store.applyingHistory) {
setStore("historyIndex", -1)
setStore("savedPrompt", null)
}
resetHistoryNavigation()
mirror.input = true
prompt.set([...rawParts, ...images], cursorPosition)
@@ -723,7 +736,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
if (last.nodeType !== Node.TEXT_NODE) {
range.setStartAfter(last)
const isBreak = last.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR"
const next = last.nextSibling
const emptyText = next?.nodeType === Node.TEXT_NODE && (next.textContent ?? "") === ""
if (isBreak && (!next || emptyText)) {
const placeholder = next && emptyText ? next : document.createTextNode("\u200B")
if (!next) last.parentNode?.insertBefore(placeholder, null)
placeholder.textContent = "\u200B"
range.setStart(placeholder, 0)
} else {
range.setStartAfter(last)
}
}
}
range.collapse(true)
@@ -732,7 +755,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
handleInput()
setStore("popover", null)
closePopover()
}
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
@@ -782,8 +805,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
promptLength,
addToHistory,
resetHistoryNavigation: () => {
setStore("historyIndex", -1)
setStore("savedPrompt", null)
resetHistoryNavigation(true)
},
setMode: (mode) => setStore("mode", mode),
setPopover: (popover) => setStore("popover", popover),
@@ -872,7 +894,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (ctrl && event.code === "KeyG") {
if (store.popover) {
setStore("popover", null)
closePopover()
event.preventDefault()
return
}
@@ -894,6 +916,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
.current()
.map((part) => ("content" in part ? part.content : ""))
.join("")
const direction = event.key === "ArrowUp" ? "up" : "down"
if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition)) return
const isEmpty = textContent.trim() === "" || textLength <= 1
const hasNewlines = textContent.includes("\n")
const inHistory = store.historyIndex >= 0
@@ -902,7 +926,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const allowUp = isEmpty || atStart || (!hasNewlines && !inHistory) || (inHistory && atEnd)
const allowDown = isEmpty || atEnd || (!hasNewlines && !inHistory) || (inHistory && atStart)
if (event.key === "ArrowUp") {
if (direction === "up") {
if (!allowUp) return
if (navigateHistory("up")) {
event.preventDefault()
@@ -923,7 +947,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
if (event.key === "Escape") {
if (store.popover) {
setStore("popover", null)
closePopover()
} else if (working()) {
abort()
}
@@ -1033,7 +1057,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
keybind={command.keybind("agent.cycle")}
>
<Select
options={local.agent.list().map((agent) => agent.name)}
options={agentNames()}
current={local.agent.current()?.name ?? ""}
onSelect={local.agent.set}
class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-full" : "max-w-[120px]"}`}
@@ -2,6 +2,7 @@ import { onCleanup, onMount } from "solid-js"
import { showToast } from "@opencode-ai/ui/toast"
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
import { useLanguage } from "@/context/language"
import { uuid } from "@/utils/uuid"
import { getCursorPosition } from "./editor-dom"
export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
@@ -31,7 +32,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const dataUrl = reader.result as string
const attachment: ImageAttachmentPart = {
type: "image",
id: crypto.randomUUID?.() ?? Math.random().toString(16).slice(2),
id: uuid(),
filename: file.name,
mime: file.type,
dataUrl,
@@ -88,6 +89,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
}
if (!plainText) return
const inserted = typeof document.execCommand === "function" && document.execCommand("insertText", false, plainText)
if (inserted) return
input.addPart({ type: "text", content: plainText, start: 0, end: 0 })
}
@@ -20,61 +20,68 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
<Show when={props.items.length > 0}>
<div class="flex flex-nowrap items-start gap-2 p-2 overflow-x-auto no-scrollbar">
<For each={props.items}>
{(item) => (
<Tooltip
value={
<span class="flex max-w-[300px]">
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
{getDirectory(item.path)}
{(item) => {
const directory = getDirectory(item.path)
const filename = getFilename(item.path)
const label = getFilenameTruncated(item.path, 14)
const selected = props.active(item)
return (
<Tooltip
value={
<span class="flex max-w-[300px]">
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
{directory}
</span>
<span class="shrink-0">{filename}</span>
</span>
<span class="shrink-0">{getFilename(item.path)}</span>
</span>
}
placement="top"
openDelay={2000}
>
<div
classList={{
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !props.active(item),
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
props.active(item),
"bg-background-stronger": !props.active(item),
}}
onClick={() => props.openComment(item)}
}
placement="top"
openDelay={2000}
>
<div class="flex items-center gap-1.5">
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
<div class="flex items-center text-11-regular min-w-0 font-medium">
<span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
<Show when={item.selection}>
{(sel) => (
<span class="text-text-weak whitespace-nowrap shrink-0">
{sel().startLine === sel().endLine
? `:${sel().startLine}`
: `:${sel().startLine}-${sel().endLine}`}
</span>
)}
</Show>
<div
classList={{
"group shrink-0 flex flex-col rounded-[6px] pl-2 pr-1 py-1 max-w-[200px] h-12 transition-all transition-transform shadow-xs-border hover:shadow-xs-border-hover": true,
"cursor-pointer hover:bg-surface-interactive-weak": !!item.commentID && !selected,
"cursor-pointer bg-surface-interactive-hover hover:bg-surface-interactive-hover shadow-xs-border-hover":
selected,
"bg-background-stronger": !selected,
}}
onClick={() => props.openComment(item)}
>
<div class="flex items-center gap-1.5">
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
<div class="flex items-center text-11-regular min-w-0 font-medium">
<span class="text-text-strong whitespace-nowrap">{label}</span>
<Show when={item.selection}>
{(sel) => (
<span class="text-text-weak whitespace-nowrap shrink-0">
{sel().startLine === sel().endLine
? `:${sel().startLine}`
: `:${sel().startLine}-${sel().endLine}`}
</span>
)}
</Show>
</div>
<IconButton
type="button"
icon="close-small"
variant="ghost"
class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
onClick={(e) => {
e.stopPropagation()
props.remove(item)
}}
aria-label={props.t("prompt.context.removeFile")}
/>
</div>
<IconButton
type="button"
icon="close-small"
variant="ghost"
class="ml-auto size-3.5 text-text-weak hover:text-text-strong transition-all"
onClick={(e) => {
e.stopPropagation()
props.remove(item)
}}
aria-label={props.t("prompt.context.removeFile")}
/>
<Show when={item.comment}>
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
</Show>
</div>
<Show when={item.comment}>
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
</Show>
</div>
</Tooltip>
)}
</Tooltip>
)
}}
</For>
</div>
</Show>
@@ -6,12 +6,17 @@ type PromptDragOverlayProps = {
label: string
}
const kindToIcon = {
image: "photo",
"@mention": "link",
} as const
export const PromptDragOverlay: Component<PromptDragOverlayProps> = (props) => {
return (
<Show when={props.type !== null}>
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
<div class="flex flex-col items-center gap-2 text-text-weak">
<Icon name={props.type === "@mention" ? "link" : "photo"} class="size-8" />
<Icon name={props.type ? kindToIcon[props.type] : kindToIcon.image} class="size-8" />
<span class="text-14-regular">{props.label}</span>
</div>
</div>
@@ -2,17 +2,26 @@ import { describe, expect, test } from "bun:test"
import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom"
describe("prompt-input editor dom", () => {
test("createTextFragment preserves newlines with br and zero-width placeholders", () => {
test("createTextFragment preserves newlines with consecutive br nodes", () => {
const fragment = createTextFragment("foo\n\nbar")
const container = document.createElement("div")
container.appendChild(fragment)
expect(container.childNodes.length).toBe(5)
expect(container.childNodes.length).toBe(4)
expect(container.childNodes[0]?.textContent).toBe("foo")
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
expect((container.childNodes[2] as HTMLElement).tagName).toBe("BR")
expect(container.childNodes[3]?.textContent).toBe("bar")
})
test("createTextFragment keeps trailing newline as terminal break", () => {
const fragment = createTextFragment("foo\n")
const container = document.createElement("div")
container.appendChild(fragment)
expect(container.childNodes.length).toBe(2)
expect(container.childNodes[0]?.textContent).toBe("foo")
expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
expect(container.childNodes[2]?.textContent).toBe("\u200B")
expect((container.childNodes[3] as HTMLElement).tagName).toBe("BR")
expect(container.childNodes[4]?.textContent).toBe("bar")
})
test("length helpers treat breaks as one char and ignore zero-width chars", () => {
@@ -48,4 +57,21 @@ describe("prompt-input editor dom", () => {
container.remove()
})
test("setCursorPosition and getCursorPosition round-trip across blank lines", () => {
const container = document.createElement("div")
container.appendChild(document.createTextNode("a"))
container.appendChild(document.createElement("br"))
container.appendChild(document.createElement("br"))
container.appendChild(document.createTextNode("b"))
document.body.appendChild(container)
setCursorPosition(container, 2)
expect(getCursorPosition(container)).toBe(2)
setCursorPosition(container, 3)
expect(getCursorPosition(container)).toBe(3)
container.remove()
})
})
@@ -4,8 +4,6 @@ export function createTextFragment(content: string): DocumentFragment {
segments.forEach((segment, index) => {
if (segment) {
fragment.appendChild(document.createTextNode(segment))
} else if (segments.length > 1) {
fragment.appendChild(document.createTextNode("\u200B"))
}
if (index < segments.length - 1) {
fragment.appendChild(document.createElement("br"))
@@ -1,6 +1,12 @@
import { describe, expect, test } from "bun:test"
import type { Prompt } from "@/context/prompt"
import { clonePromptParts, navigatePromptHistory, prependHistoryEntry, promptLength } from "./history"
import {
canNavigateHistoryAtCursor,
clonePromptParts,
navigatePromptHistory,
prependHistoryEntry,
promptLength,
} from "./history"
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
@@ -66,4 +72,20 @@ describe("prompt-input history", () => {
if (original[1]?.type !== "file") throw new Error("expected file")
expect(original[1].selection?.startLine).toBe(1)
})
test("canNavigateHistoryAtCursor only allows multiline boundaries", () => {
const value = "a\nb\nc"
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true)
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 2)).toBe(false)
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(true)
})
})
@@ -4,6 +4,13 @@ const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
export const MAX_HISTORY = 100
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number) {
if (!text.includes("\n")) return true
const position = Math.max(0, Math.min(cursor, text.length))
if (direction === "up") return !text.slice(0, position).includes("\n")
return !text.slice(position).includes("\n")
}
export function clonePromptParts(prompt: Prompt): Prompt {
return prompt.map((part) => {
if (part.type === "text") return { ...part }
@@ -9,6 +9,13 @@ type PromptImageAttachmentsProps = {
removeLabel: string
}
const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base"
const imageClass =
"size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
const removeClass =
"absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md"
export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (props) => {
return (
<Show when={props.attachments.length > 0}>
@@ -19,7 +26,7 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
<Show
when={attachment.mime.startsWith("image/")}
fallback={
<div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
<div class={fallbackClass}>
<Icon name="folder" class="size-6 text-text-weak" />
</div>
}
@@ -27,19 +34,19 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
<img
src={attachment.dataUrl}
alt={attachment.filename}
class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
class={imageClass}
onClick={() => props.onOpen(attachment)}
/>
</Show>
<button
type="button"
onClick={() => props.onRemove(attachment.id)}
class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
class={removeClass}
aria-label={props.removeLabel}
>
<Icon name="close" class="size-3 text-text-weak" />
</button>
<div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
<div class={nameClass}>
<span class="text-10-regular text-white truncate block">{attachment.filename}</span>
</div>
</div>
@@ -52,47 +52,44 @@ export const PromptPopover: Component<PromptPopoverProps> = (props) => {
fallback={<div class="text-text-weak px-2 py-1">{props.t("prompt.popover.emptyResults")}</div>}
>
<For each={props.atFlat.slice(0, 10)}>
{(item) => (
<button
classList={{
"w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
"bg-surface-raised-base-hover": props.atActive === props.atKey(item),
}}
onClick={() => props.onAtSelect(item)}
onMouseEnter={() => props.setAtActive(props.atKey(item))}
>
<Show
when={item.type === "agent"}
fallback={
<>
<FileIcon
node={{ path: item.type === "file" ? item.path : "", type: "file" }}
class="shrink-0 size-4"
/>
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">
{item.type === "file"
? item.path.endsWith("/")
? item.path
: getDirectory(item.path)
: ""}
</span>
<Show when={item.type === "file" && !item.path.endsWith("/")}>
<span class="text-text-strong whitespace-nowrap">
{item.type === "file" ? getFilename(item.path) : ""}
</span>
</Show>
</div>
</>
}
{(item) => {
const key = props.atKey(item)
if (item.type === "agent") {
return (
<button
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
onClick={() => props.onAtSelect(item)}
onMouseEnter={() => props.setAtActive(key)}
>
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
<span class="text-14-regular text-text-strong whitespace-nowrap">@{item.name}</span>
</button>
)
}
const isDirectory = item.path.endsWith("/")
const directory = isDirectory ? item.path : getDirectory(item.path)
const filename = isDirectory ? "" : getFilename(item.path)
return (
<button
class="w-full flex items-center gap-x-2 rounded-md px-2 py-0.5"
classList={{ "bg-surface-raised-base-hover": props.atActive === key }}
onClick={() => props.onAtSelect(item)}
onMouseEnter={() => props.setAtActive(key)}
>
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
<span class="text-14-regular text-text-strong whitespace-nowrap">
@{item.type === "agent" ? item.name : ""}
</span>
</Show>
</button>
)}
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{directory}</span>
<Show when={!isDirectory}>
<span class="text-text-strong whitespace-nowrap">{filename}</span>
</Show>
</div>
</button>
)
}}
</For>
</Show>
</Match>
@@ -385,7 +385,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const send = async () => {
const ok = await waitForWorktree()
if (!ok) return
await client.session.prompt({
await client.session.promptAsync({
sessionID: session.id,
agent,
model,
+29 -37
View File
@@ -38,43 +38,45 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
showToast({ title: language.t("common.requestFailed"), description: message })
}
const reply = (answers: QuestionAnswer[]) => {
const reply = async (answers: QuestionAnswer[]) => {
if (store.sending) return
setStore("sending", true)
sdk.client.question
.reply({ requestID: props.request.id, answers })
.catch(fail)
.finally(() => setStore("sending", false))
try {
await sdk.client.question.reply({ requestID: props.request.id, answers })
} catch (err) {
fail(err)
} finally {
setStore("sending", false)
}
}
const reject = () => {
const reject = async () => {
if (store.sending) return
setStore("sending", true)
sdk.client.question
.reject({ requestID: props.request.id })
.catch(fail)
.finally(() => setStore("sending", false))
try {
await sdk.client.question.reject({ requestID: props.request.id })
} catch (err) {
fail(err)
} finally {
setStore("sending", false)
}
}
const submit = () => {
reply(questions().map((_, i) => store.answers[i] ?? []))
void reply(questions().map((_, i) => store.answers[i] ?? []))
}
const pick = (answer: string, custom: boolean = false) => {
const answers = [...store.answers]
answers[store.tab] = [answer]
setStore("answers", answers)
setStore("answers", store.tab, [answer])
if (custom) {
const inputs = [...store.custom]
inputs[store.tab] = answer
setStore("custom", inputs)
setStore("custom", store.tab, answer)
}
if (single()) {
reply([[answer]])
void reply([[answer]])
return
}
@@ -82,15 +84,10 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
}
const toggle = (answer: string) => {
const existing = store.answers[store.tab] ?? []
const next = [...existing]
const index = next.indexOf(answer)
if (index === -1) next.push(answer)
if (index !== -1) next.splice(index, 1)
const answers = [...store.answers]
answers[store.tab] = next
setStore("answers", answers)
setStore("answers", store.tab, (current = []) => {
if (current.includes(answer)) return current.filter((item) => item !== answer)
return [...current, answer]
})
}
const selectTab = (index: number) => {
@@ -126,13 +123,10 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
}
if (multi()) {
const existing = store.answers[store.tab] ?? []
const next = [...existing]
if (!next.includes(value)) next.push(value)
const answers = [...store.answers]
answers[store.tab] = next
setStore("answers", answers)
setStore("answers", store.tab, (current = []) => {
if (current.includes(value)) return current
return [...current, value]
})
setStore("editing", false)
return
}
@@ -225,9 +219,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) =>
value={input()}
disabled={store.sending}
onInput={(e) => {
const inputs = [...store.custom]
inputs[store.tab] = e.currentTarget.value
setStore("custom", inputs)
setStore("custom", store.tab, e.currentTarget.value)
}}
/>
<Button type="submit" variant="primary" size="small" disabled={store.sending}>
@@ -1,5 +1,5 @@
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { JSXElement, ParentProps, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
import { JSXElement, ParentProps, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { serverDisplayName } from "@/context/server"
import type { ServerHealth } from "@/utils/server-health"
@@ -17,6 +17,7 @@ export function ServerRow(props: ServerRowProps) {
const [truncated, setTruncated] = createSignal(false)
let nameRef: HTMLSpanElement | undefined
let versionRef: HTMLSpanElement | undefined
const name = createMemo(() => serverDisplayName(props.url))
const check = () => {
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
@@ -25,25 +26,24 @@ export function ServerRow(props: ServerRowProps) {
}
createEffect(() => {
name()
props.url
props.status?.version
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(check)
return
}
check()
queueMicrotask(check)
})
onMount(() => {
check()
if (typeof window === "undefined") return
window.addEventListener("resize", check)
onCleanup(() => window.removeEventListener("resize", check))
if (typeof ResizeObserver !== "function") return
const observer = new ResizeObserver(check)
if (nameRef) observer.observe(nameRef)
if (versionRef) observer.observe(versionRef)
onCleanup(() => observer.disconnect())
})
const tooltipValue = () => (
<span class="flex items-center gap-2">
<span>{serverDisplayName(props.url)}</span>
<span>{name()}</span>
<Show when={props.status?.version}>
<span class="text-text-invert-base">{props.status?.version}</span>
</Show>
@@ -62,7 +62,7 @@ export function ServerRow(props: ServerRowProps) {
}}
/>
<span ref={nameRef} class={props.nameClass ?? "truncate"}>
{serverDisplayName(props.url)}
{name()}
</span>
<Show when={props.status?.version}>
<span ref={versionRef} class={props.versionClass ?? "text-text-weak text-14-regular truncate"}>
@@ -13,6 +13,17 @@ interface SessionContextUsageProps {
variant?: "button" | "indicator"
}
function openSessionContext(args: {
view: ReturnType<ReturnType<typeof useLayout>["view"]>
layout: ReturnType<typeof useLayout>
tabs: ReturnType<ReturnType<typeof useLayout>["tabs"]>
}) {
if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open()
if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all")
args.tabs.open("context")
args.tabs.setActive("context")
}
export function SessionContextUsage(props: SessionContextUsageProps) {
const sync = useSync()
const params = useParams()
@@ -41,11 +52,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const openContext = () => {
if (!params.id) return
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.open()
layout.fileTree.setTab("all")
tabs().open("context")
tabs().setActive("context")
openSessionContext({
view: view(),
layout,
tabs: tabs(),
})
}
const circle = () => (
@@ -0,0 +1,61 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { estimateSessionContextBreakdown } from "./session-context-breakdown"
const user = (id: string) => {
return {
id,
role: "user",
time: { created: 1 },
} as unknown as Message
}
const assistant = (id: string) => {
return {
id,
role: "assistant",
time: { created: 1 },
} as unknown as Message
}
describe("estimateSessionContextBreakdown", () => {
test("estimates tokens and keeps remaining tokens as other", () => {
const messages = [user("u1"), assistant("a1")]
const parts = {
u1: [{ type: "text", text: "hello world" }] as unknown as Part[],
a1: [{ type: "text", text: "assistant response" }] as unknown as Part[],
}
const output = estimateSessionContextBreakdown({
messages,
parts,
input: 20,
systemPrompt: "system prompt",
})
const map = Object.fromEntries(output.map((segment) => [segment.key, segment.tokens]))
expect(map.system).toBe(4)
expect(map.user).toBe(3)
expect(map.assistant).toBe(5)
expect(map.other).toBe(8)
})
test("scales segments when estimates exceed input", () => {
const messages = [user("u1"), assistant("a1")]
const parts = {
u1: [{ type: "text", text: "x".repeat(400) }] as unknown as Part[],
a1: [{ type: "text", text: "y".repeat(400) }] as unknown as Part[],
}
const output = estimateSessionContextBreakdown({
messages,
parts,
input: 10,
systemPrompt: "z".repeat(200),
})
const total = output.reduce((sum, segment) => sum + segment.tokens, 0)
expect(total).toBeLessThanOrEqual(10)
expect(output.every((segment) => segment.width <= 100)).toBeTrue()
})
})
@@ -0,0 +1,132 @@
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
export type SessionContextBreakdownKey = "system" | "user" | "assistant" | "tool" | "other"
export type SessionContextBreakdownSegment = {
key: SessionContextBreakdownKey
tokens: number
width: number
percent: number
}
const estimateTokens = (chars: number) => Math.ceil(chars / 4)
const toPercent = (tokens: number, input: number) => (tokens / input) * 100
const toPercentLabel = (tokens: number, input: number) => Math.round(toPercent(tokens, input) * 10) / 10
const charsFromUserPart = (part: Part) => {
if (part.type === "text") return part.text.length
if (part.type === "file") return part.source?.text.value.length ?? 0
if (part.type === "agent") return part.source?.value.length ?? 0
return 0
}
const charsFromAssistantPart = (part: Part) => {
if (part.type === "text") return { assistant: part.text.length, tool: 0 }
if (part.type === "reasoning") return { assistant: part.text.length, tool: 0 }
if (part.type !== "tool") return { assistant: 0, tool: 0 }
const input = Object.keys(part.state.input).length * 16
if (part.state.status === "pending") return { assistant: 0, tool: input + part.state.raw.length }
if (part.state.status === "completed") return { assistant: 0, tool: input + part.state.output.length }
if (part.state.status === "error") return { assistant: 0, tool: input + part.state.error.length }
return { assistant: 0, tool: input }
}
const build = (
tokens: { system: number; user: number; assistant: number; tool: number; other: number },
input: number,
) => {
return [
{
key: "system",
tokens: tokens.system,
},
{
key: "user",
tokens: tokens.user,
},
{
key: "assistant",
tokens: tokens.assistant,
},
{
key: "tool",
tokens: tokens.tool,
},
{
key: "other",
tokens: tokens.other,
},
]
.filter((x) => x.tokens > 0)
.map((x) => ({
key: x.key,
tokens: x.tokens,
width: toPercent(x.tokens, input),
percent: toPercentLabel(x.tokens, input),
})) as SessionContextBreakdownSegment[]
}
export function estimateSessionContextBreakdown(args: {
messages: Message[]
parts: Record<string, Part[] | undefined>
input: number
systemPrompt?: string
}) {
if (!args.input) return []
const counts = args.messages.reduce(
(acc, msg) => {
const parts = args.parts[msg.id] ?? []
if (msg.role === "user") {
const user = parts.reduce((sum, part) => sum + charsFromUserPart(part), 0)
return { ...acc, user: acc.user + user }
}
if (msg.role !== "assistant") return acc
const assistant = parts.reduce(
(sum, part) => {
const next = charsFromAssistantPart(part)
return {
assistant: sum.assistant + next.assistant,
tool: sum.tool + next.tool,
}
},
{ assistant: 0, tool: 0 },
)
return {
...acc,
assistant: acc.assistant + assistant.assistant,
tool: acc.tool + assistant.tool,
}
},
{
system: args.systemPrompt?.length ?? 0,
user: 0,
assistant: 0,
tool: 0,
},
)
const tokens = {
system: estimateTokens(counts.system),
user: estimateTokens(counts.user),
assistant: estimateTokens(counts.assistant),
tool: estimateTokens(counts.tool),
}
const estimated = tokens.system + tokens.user + tokens.assistant + tokens.tool
if (estimated <= args.input) {
return build({ ...tokens, other: args.input - estimated }, args.input)
}
const scale = args.input / estimated
const scaled = {
system: Math.floor(tokens.system * scale),
user: Math.floor(tokens.user * scale),
assistant: Math.floor(tokens.assistant * scale),
tool: Math.floor(tokens.tool * scale),
}
const total = scaled.system + scaled.user + scaled.assistant + scaled.tool
return build({ ...scaled, other: Math.max(0, args.input - total) }, args.input)
}
@@ -0,0 +1,20 @@
import { DateTime } from "luxon"
export function createSessionContextFormatter(locale: string) {
return {
number(value: number | null | undefined) {
if (value === undefined) return "—"
if (value === null) return "—"
return value.toLocaleString(locale)
},
percent(value: number | null | undefined) {
if (value === undefined) return "—"
if (value === null) return "—"
return value.toLocaleString(locale) + "%"
},
time(value: number | undefined) {
if (!value) return "—"
return DateTime.fromMillis(value).setLocale(locale).toLocaleString(DateTime.DATETIME_MED)
},
}
}
@@ -91,4 +91,11 @@ describe("getSessionContextMetrics", () => {
expect(two.context?.message.id).toBe("a2")
expect(two.totalCost).toBe(1)
})
test("returns empty metrics when inputs are undefined", () => {
const metrics = getSessionContextMetrics(undefined, undefined)
expect(metrics.totalCost).toBe(0)
expect(metrics.context).toBeUndefined()
})
})
@@ -47,7 +47,7 @@ const lastAssistantWithTokens = (messages: Message[]) => {
}
}
const build = (messages: Message[], providers: Provider[]): Metrics => {
const build = (messages: Message[] = [], providers: Provider[] = []): Metrics => {
const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
const message = lastAssistantWithTokens(messages)
if (!message) return { totalCost, context: undefined }
@@ -77,6 +77,6 @@ const build = (messages: Message[], providers: Provider[]): Metrics => {
}
}
export function getSessionContextMetrics(messages: Message[], providers: Provider[]) {
export function getSessionContextMetrics(messages: Message[] = [], providers: Provider[] = []) {
return build(messages, providers)
}
@@ -1,7 +1,6 @@
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
import type { JSX } from "solid-js"
import { useParams } from "@solidjs/router"
import { DateTime } from "luxon"
import { useSync } from "@/context/sync"
import { useLayout } from "@/context/layout"
import { checksum } from "@opencode-ai/util/encode"
@@ -14,6 +13,8 @@ import { Markdown } from "@opencode-ai/ui/markdown"
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language"
import { getSessionContextMetrics } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format"
interface SessionContextTabProps {
messages: () => Message[]
@@ -22,6 +23,74 @@ interface SessionContextTabProps {
info: () => ReturnType<ReturnType<typeof useSync>["session"]["get"]>
}
const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
system: "var(--syntax-info)",
user: "var(--syntax-success)",
assistant: "var(--syntax-property)",
tool: "var(--syntax-warning)",
other: "var(--syntax-comment)",
}
function Stat(props: { label: string; value: JSX.Element }) {
return (
<div class="flex flex-col gap-1">
<div class="text-12-regular text-text-weak">{props.label}</div>
<div class="text-12-medium text-text-strong">{props.value}</div>
</div>
)
}
function RawMessageContent(props: { message: Message; getParts: (id: string) => Part[]; onRendered: () => void }) {
const file = createMemo(() => {
const parts = props.getParts(props.message.id)
const contents = JSON.stringify({ message: props.message, parts }, null, 2)
return {
name: `${props.message.role}-${props.message.id}.json`,
contents,
cacheKey: checksum(contents),
}
})
return (
<Code
file={file()}
overflow="wrap"
class="select-text"
onRendered={() => requestAnimationFrame(props.onRendered)}
/>
)
}
function RawMessage(props: {
message: Message
getParts: (id: string) => Part[]
onRendered: () => void
time: (value: number | undefined) => string
}) {
return (
<Accordion.Item value={props.message.id}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div class="flex items-center justify-between gap-2 w-full">
<div class="min-w-0 truncate">
{props.message.role} <span class="text-text-base"> {props.message.id}</span>
</div>
<div class="flex items-center gap-3">
<div class="shrink-0 text-12-regular text-text-weak">{props.time(props.message.time.created)}</div>
<Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" />
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content class="bg-background-base">
<div class="p-3">
<RawMessageContent message={props.message} getParts={props.getParts} onRendered={props.onRendered} />
</div>
</Accordion.Content>
</Accordion.Item>
)
}
export function SessionContextTab(props: SessionContextTabProps) {
const params = useParams()
const sync = useSync()
@@ -37,6 +106,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
const ctx = createMemo(() => metrics().context)
const formatter = createMemo(() => createSessionContextFormatter(language.locale()))
const cost = createMemo(() => {
return usd().format(metrics().totalCost)
@@ -62,23 +132,6 @@ export function SessionContextTab(props: SessionContextTabProps) {
return trimmed
})
const number = (value: number | null | undefined) => {
if (value === undefined) return "—"
if (value === null) return "—"
return value.toLocaleString(language.locale())
}
const percent = (value: number | null | undefined) => {
if (value === undefined) return "—"
if (value === null) return "—"
return value.toLocaleString(language.locale()) + "%"
}
const time = (value: number | undefined) => {
if (!value) return "—"
return DateTime.fromMillis(value).setLocale(language.locale()).toLocaleString(DateTime.DATETIME_MED)
}
const providerLabel = createMemo(() => {
const c = ctx()
if (!c) return "—"
@@ -96,197 +149,51 @@ export function SessionContextTab(props: SessionContextTabProps) {
() => [ctx()?.message.id, ctx()?.input, props.messages().length, systemPrompt()],
() => {
const c = ctx()
if (!c) return []
const input = c.input
if (!input) return []
const out = {
system: systemPrompt()?.length ?? 0,
user: 0,
assistant: 0,
tool: 0,
}
for (const msg of props.messages()) {
const parts = (sync.data.part[msg.id] ?? []) as Part[]
if (msg.role === "user") {
for (const part of parts) {
if (part.type === "text") out.user += part.text.length
if (part.type === "file") out.user += part.source?.text.value.length ?? 0
if (part.type === "agent") out.user += part.source?.value.length ?? 0
}
continue
}
if (msg.role === "assistant") {
for (const part of parts) {
if (part.type === "text") out.assistant += part.text.length
if (part.type === "reasoning") out.assistant += part.text.length
if (part.type === "tool") {
out.tool += Object.keys(part.state.input).length * 16
if (part.state.status === "pending") out.tool += part.state.raw.length
if (part.state.status === "completed") out.tool += part.state.output.length
if (part.state.status === "error") out.tool += part.state.error.length
}
}
}
}
const estimateTokens = (chars: number) => Math.ceil(chars / 4)
const system = estimateTokens(out.system)
const user = estimateTokens(out.user)
const assistant = estimateTokens(out.assistant)
const tool = estimateTokens(out.tool)
const estimated = system + user + assistant + tool
const pct = (tokens: number) => (tokens / input) * 100
const pctLabel = (tokens: number) => (Math.round(pct(tokens) * 10) / 10).toString() + "%"
const build = (tokens: { system: number; user: number; assistant: number; tool: number; other: number }) => {
return [
{
key: "system",
label: language.t("context.breakdown.system"),
tokens: tokens.system,
width: pct(tokens.system),
percent: pctLabel(tokens.system),
color: "var(--syntax-info)",
},
{
key: "user",
label: language.t("context.breakdown.user"),
tokens: tokens.user,
width: pct(tokens.user),
percent: pctLabel(tokens.user),
color: "var(--syntax-success)",
},
{
key: "assistant",
label: language.t("context.breakdown.assistant"),
tokens: tokens.assistant,
width: pct(tokens.assistant),
percent: pctLabel(tokens.assistant),
color: "var(--syntax-property)",
},
{
key: "tool",
label: language.t("context.breakdown.tool"),
tokens: tokens.tool,
width: pct(tokens.tool),
percent: pctLabel(tokens.tool),
color: "var(--syntax-warning)",
},
{
key: "other",
label: language.t("context.breakdown.other"),
tokens: tokens.other,
width: pct(tokens.other),
percent: pctLabel(tokens.other),
color: "var(--syntax-comment)",
},
].filter((x) => x.tokens > 0)
}
if (estimated <= input) {
return build({ system, user, assistant, tool, other: input - estimated })
}
const scale = input / estimated
const scaled = {
system: Math.floor(system * scale),
user: Math.floor(user * scale),
assistant: Math.floor(assistant * scale),
tool: Math.floor(tool * scale),
}
const scaledTotal = scaled.system + scaled.user + scaled.assistant + scaled.tool
return build({ ...scaled, other: Math.max(0, input - scaledTotal) })
if (!c?.input) return []
return estimateSessionContextBreakdown({
messages: props.messages(),
parts: sync.data.part as Record<string, Part[] | undefined>,
input: c.input,
systemPrompt: systemPrompt(),
})
},
),
)
function Stat(statProps: { label: string; value: JSX.Element }) {
return (
<div class="flex flex-col gap-1">
<div class="text-12-regular text-text-weak">{statProps.label}</div>
<div class="text-12-medium text-text-strong">{statProps.value}</div>
</div>
)
const breakdownLabel = (key: SessionContextBreakdownKey) => {
if (key === "system") return language.t("context.breakdown.system")
if (key === "user") return language.t("context.breakdown.user")
if (key === "assistant") return language.t("context.breakdown.assistant")
if (key === "tool") return language.t("context.breakdown.tool")
return language.t("context.breakdown.other")
}
const stats = createMemo(() => {
const c = ctx()
const count = counts()
return [
{ label: language.t("context.stats.session"), value: props.info()?.title ?? params.id ?? "—" },
{ label: language.t("context.stats.messages"), value: count.all.toLocaleString(language.locale()) },
{ label: language.t("context.stats.provider"), value: providerLabel() },
{ label: language.t("context.stats.model"), value: modelLabel() },
{ label: language.t("context.stats.limit"), value: number(c?.limit) },
{ label: language.t("context.stats.totalTokens"), value: number(c?.total) },
{ label: language.t("context.stats.usage"), value: percent(c?.usage) },
{ label: language.t("context.stats.inputTokens"), value: number(c?.input) },
{ label: language.t("context.stats.outputTokens"), value: number(c?.output) },
{ label: language.t("context.stats.reasoningTokens"), value: number(c?.reasoning) },
{
label: language.t("context.stats.cacheTokens"),
value: `${number(c?.cacheRead)} / ${number(c?.cacheWrite)}`,
},
{ label: language.t("context.stats.userMessages"), value: count.user.toLocaleString(language.locale()) },
{
label: language.t("context.stats.assistantMessages"),
value: count.assistant.toLocaleString(language.locale()),
},
{ label: language.t("context.stats.totalCost"), value: cost() },
{ label: language.t("context.stats.sessionCreated"), value: time(props.info()?.time.created) },
{ label: language.t("context.stats.lastActivity"), value: time(c?.message.time.created) },
] satisfies { label: string; value: JSX.Element }[]
})
function RawMessageContent(msgProps: { message: Message }) {
const file = createMemo(() => {
const parts = (sync.data.part[msgProps.message.id] ?? []) as Part[]
const contents = JSON.stringify({ message: msgProps.message, parts }, null, 2)
return {
name: `${msgProps.message.role}-${msgProps.message.id}.json`,
contents,
cacheKey: checksum(contents),
}
})
return (
<Code file={file()} overflow="wrap" class="select-text" onRendered={() => requestAnimationFrame(restoreScroll)} />
)
}
function RawMessage(msgProps: { message: Message }) {
return (
<Accordion.Item value={msgProps.message.id}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div class="flex items-center justify-between gap-2 w-full">
<div class="min-w-0 truncate">
{msgProps.message.role} <span class="text-text-base"> {msgProps.message.id}</span>
</div>
<div class="flex items-center gap-3">
<div class="shrink-0 text-12-regular text-text-weak">{time(msgProps.message.time.created)}</div>
<Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" />
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content class="bg-background-base">
<div class="p-3">
<RawMessageContent message={msgProps.message} />
</div>
</Accordion.Content>
</Accordion.Item>
)
}
const stats = [
{ label: "context.stats.session", value: () => props.info()?.title ?? params.id ?? "—" },
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.locale()) },
{ label: "context.stats.provider", value: providerLabel },
{ label: "context.stats.model", value: modelLabel },
{ label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) },
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.output) },
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.reasoning) },
{
label: "context.stats.cacheTokens",
value: () => `${formatter().number(ctx()?.cacheRead)} / ${formatter().number(ctx()?.cacheWrite)}`,
},
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.locale()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.locale()) },
{ label: "context.stats.totalCost", value: cost },
{ label: "context.stats.sessionCreated", value: () => formatter().time(props.info()?.time.created) },
{ label: "context.stats.lastActivity", value: () => formatter().time(ctx()?.message.time.created) },
] satisfies { label: string; value: () => JSX.Element }[]
let scroll: HTMLDivElement | undefined
let frame: number | undefined
let pending: { x: number; y: number } | undefined
const getParts = (id: string) => (sync.data.part[id] ?? []) as Part[]
const restoreScroll = () => {
const el = scroll
@@ -343,7 +250,9 @@ export function SessionContextTab(props: SessionContextTabProps) {
>
<div class="px-6 pt-4 flex flex-col gap-10">
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
<For each={stats()}>{(stat) => <Stat label={stat.label} value={stat.value} />}</For>
<For each={stats}>
{(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}
</For>
</div>
<Show when={breakdown().length > 0}>
@@ -356,7 +265,7 @@ export function SessionContextTab(props: SessionContextTabProps) {
class="h-full"
style={{
width: `${segment.width}%`,
"background-color": segment.color,
"background-color": BREAKDOWN_COLOR[segment.key],
}}
/>
)}
@@ -366,9 +275,9 @@ export function SessionContextTab(props: SessionContextTabProps) {
<For each={breakdown()}>
{(segment) => (
<div class="flex items-center gap-1 text-11-regular text-text-weak">
<div class="size-2 rounded-sm" style={{ "background-color": segment.color }} />
<div>{segment.label}</div>
<div class="text-text-weaker">{segment.percent}</div>
<div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} />
<div>{breakdownLabel(segment.key)}</div>
<div class="text-text-weaker">{segment.percent.toLocaleString(language.locale())}%</div>
</div>
)}
</For>
@@ -391,7 +300,11 @@ export function SessionContextTab(props: SessionContextTabProps) {
<div class="flex flex-col gap-2">
<div class="text-12-regular text-text-weak">{language.t("context.rawMessages.title")}</div>
<Accordion multiple>
<For each={props.messages()}>{(message) => <RawMessage message={message} />}</For>
<For each={props.messages()}>
{(message) => (
<RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
)}
</For>
</Accordion>
</div>
</div>
@@ -1,4 +1,4 @@
import { createEffect, createMemo, onCleanup, Show } from "solid-js"
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useParams } from "@solidjs/router"
@@ -25,6 +25,164 @@ import { Keybind } from "@opencode-ai/ui/keybind"
import { showToast } from "@opencode-ai/ui/toast"
import { StatusPopover } from "../status-popover"
const OPEN_APPS = [
"vscode",
"cursor",
"zed",
"textmate",
"antigravity",
"finder",
"terminal",
"iterm2",
"ghostty",
"xcode",
"android-studio",
"powershell",
"sublime-text",
] as const
type OpenApp = (typeof OPEN_APPS)[number]
type OS = "macos" | "windows" | "linux" | "unknown"
const MAC_APPS = [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
{ id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
{ id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" },
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
{ id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" },
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
] as const
const WINDOWS_APPS = [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
{ id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" },
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
] as const
const LINUX_APPS = [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
] as const
type OpenOption = (typeof MAC_APPS)[number] | (typeof WINDOWS_APPS)[number] | (typeof LINUX_APPS)[number]
type OpenIcon = OpenApp | "file-explorer"
const OPEN_ICON_BASE = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"])
const openIconSize = (id: OpenIcon) => (OPEN_ICON_BASE.has(id) ? "size-4" : "size-[19px]")
const detectOS = (platform: ReturnType<typeof usePlatform>): OS => {
if (platform.platform === "desktop" && platform.os) return platform.os
if (typeof navigator !== "object") return "unknown"
const value = navigator.platform || navigator.userAgent
if (/Mac/i.test(value)) return "macos"
if (/Win/i.test(value)) return "windows"
if (/Linux/i.test(value)) return "linux"
return "unknown"
}
const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
function useSessionShare(args: {
globalSDK: ReturnType<typeof useGlobalSDK>
currentSession: () =>
| {
id: string
share?: {
url?: string
}
}
| undefined
projectDirectory: () => string
platform: ReturnType<typeof usePlatform>
}) {
const [state, setState] = createStore({
share: false,
unshare: false,
copied: false,
timer: undefined as number | undefined,
})
const shareUrl = createMemo(() => args.currentSession()?.share?.url)
createEffect(() => {
const url = shareUrl()
if (url) return
if (state.timer) window.clearTimeout(state.timer)
setState({ copied: false, timer: undefined })
})
onCleanup(() => {
if (state.timer) window.clearTimeout(state.timer)
})
const shareSession = () => {
const session = args.currentSession()
if (!session || state.share) return
setState("share", true)
args.globalSDK.client.session
.share({ sessionID: session.id, directory: args.projectDirectory() })
.catch((error) => {
console.error("Failed to share session", error)
})
.finally(() => {
setState("share", false)
})
}
const unshareSession = () => {
const session = args.currentSession()
if (!session || state.unshare) return
setState("unshare", true)
args.globalSDK.client.session
.unshare({ sessionID: session.id, directory: args.projectDirectory() })
.catch((error) => {
console.error("Failed to unshare session", error)
})
.finally(() => {
setState("unshare", false)
})
}
const copyLink = (onError: (error: unknown) => void) => {
const url = shareUrl()
if (!url) return
navigator.clipboard
.writeText(url)
.then(() => {
if (state.timer) window.clearTimeout(state.timer)
setState("copied", true)
const timer = window.setTimeout(() => {
setState("copied", false)
setState("timer", undefined)
}, 3000)
setState("timer", timer)
})
.catch(onError)
}
const viewShare = () => {
const url = shareUrl()
if (!url) return
args.platform.openLink(url)
}
return { state, shareUrl, shareSession, unshareSession, copyLink, viewShare }
}
export function SessionHeader() {
const globalSDK = useGlobalSDK()
const layout = useLayout()
@@ -53,62 +211,7 @@ export function SessionHeader() {
const showShare = createMemo(() => shareEnabled() && !!currentSession())
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey))
const OPEN_APPS = [
"vscode",
"cursor",
"zed",
"textmate",
"antigravity",
"finder",
"terminal",
"iterm2",
"ghostty",
"xcode",
"android-studio",
"powershell",
"sublime-text",
] as const
type OpenApp = (typeof OPEN_APPS)[number]
const MAC_APPS = [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
{ id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
{ id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" },
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
{ id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" },
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
] as const
const WINDOWS_APPS = [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
{ id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" },
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
] as const
const LINUX_APPS = [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
{ id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
] as const
const os = createMemo<"macos" | "windows" | "linux" | "unknown">(() => {
if (platform.platform === "desktop" && platform.os) return platform.os
if (typeof navigator !== "object") return "unknown"
const value = navigator.platform || navigator.userAgent
if (/Mac/i.test(value)) return "macos"
if (/Win/i.test(value)) return "windows"
if (/Linux/i.test(value)) return "linux"
return "unknown"
})
const os = createMemo(() => detectOS(platform))
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({ finder: true })
@@ -154,10 +257,6 @@ export function SessionHeader() {
] as const
})
type OpenIcon = OpenApp | "file-explorer"
const base = new Set<OpenIcon>(["finder", "vscode", "cursor", "zed"])
const size = (id: OpenIcon) => (base.has(id) ? "size-4" : "size-[19px]")
const checksReady = createMemo(() => {
if (platform.platform !== "desktop") return true
if (!platform.checkAppExists) return true
@@ -166,6 +265,7 @@ export function SessionHeader() {
})
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
const [menu, setMenu] = createStore({ open: false })
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
@@ -185,13 +285,7 @@ export function SessionHeader() {
const item = options().find((o) => o.id === app)
const openWith = item && "openWith" in item ? item.openWith : undefined
Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
})
Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => showRequestError(language, err))
}
const copyPath = () => {
@@ -207,93 +301,24 @@ export function SessionHeader() {
description: directory,
})
})
.catch((err: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
})
.catch((err: unknown) => showRequestError(language, err))
}
const [state, setState] = createStore({
share: false,
unshare: false,
copied: false,
timer: undefined as number | undefined,
})
const shareUrl = createMemo(() => currentSession()?.share?.url)
createEffect(() => {
const url = shareUrl()
if (url) return
if (state.timer) window.clearTimeout(state.timer)
setState({ copied: false, timer: undefined })
const share = useSessionShare({
globalSDK,
currentSession,
projectDirectory,
platform,
})
onCleanup(() => {
if (state.timer) window.clearTimeout(state.timer)
})
function shareSession() {
const session = currentSession()
if (!session || state.share) return
setState("share", true)
globalSDK.client.session
.share({ sessionID: session.id, directory: projectDirectory() })
.catch((error) => {
console.error("Failed to share session", error)
})
.finally(() => {
setState("share", false)
})
}
function unshareSession() {
const session = currentSession()
if (!session || state.unshare) return
setState("unshare", true)
globalSDK.client.session
.unshare({ sessionID: session.id, directory: projectDirectory() })
.catch((error) => {
console.error("Failed to unshare session", error)
})
.finally(() => {
setState("unshare", false)
})
}
function copyLink() {
const url = shareUrl()
if (!url) return
navigator.clipboard
.writeText(url)
.then(() => {
if (state.timer) window.clearTimeout(state.timer)
setState("copied", true)
const timer = window.setTimeout(() => {
setState("copied", false)
setState("timer", undefined)
}, 3000)
setState("timer", timer)
})
.catch((error) => {
console.error("Failed to copy share link", error)
})
}
function viewShare() {
const url = shareUrl()
if (!url) return
platform.openLink(url)
}
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
const leftMount = createMemo(
() => document.getElementById("opencode-titlebar-left") ?? document.getElementById("opencode-titlebar-center"),
)
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
return (
<>
<Show when={centerMount()}>
<Show when={leftMount()}>
{(mount) => (
<Portal mount={mount()}>
<button
@@ -328,17 +353,19 @@ export function SessionHeader() {
<Show
when={canOpen()}
fallback={
<Button
variant="ghost"
class="rounded-sm h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none"
onClick={copyPath}
aria-label={language.t("session.header.open.copyPath")}
>
<Icon name="copy" size="small" class="text-icon-base" />
<span class="text-12-regular text-text-strong">
{language.t("session.header.open.copyPath")}
</span>
</Button>
<div class="flex h-[24px] box-border items-center rounded-md border border-border-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-2 gap-2 border-none shadow-none"
onClick={copyPath}
aria-label={language.t("session.header.open.copyPath")}
>
<Icon name="copy" size="small" class="text-icon-base" />
<span class="text-12-regular text-text-strong">
{language.t("session.header.open.copyPath")}
</span>
</Button>
</div>
}
>
<div class="flex items-center">
@@ -355,7 +382,12 @@ export function SessionHeader() {
<span class="text-12-regular text-text-strong">Open</span>
</Button>
<div class="self-stretch w-px bg-border-base/70" />
<DropdownMenu gutter={6} placement="bottom-end">
<DropdownMenu
gutter={6}
placement="bottom-end"
open={menu.open}
onOpenChange={(open) => setMenu("open", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="chevron-down"
@@ -374,21 +406,34 @@ export function SessionHeader() {
setPrefs("app", value as OpenApp)
}}
>
{options().map((o) => (
<DropdownMenu.RadioItem value={o.id} onSelect={() => openDir(o.id)}>
<div class="flex size-5 shrink-0 items-center justify-center">
<AppIcon id={o.icon} class={size(o.icon)} />
</div>
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator>
<Icon name="check-small" size="small" class="text-icon-weak" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
))}
<For each={options()}>
{(o) => (
<DropdownMenu.RadioItem
value={o.id}
onSelect={() => {
setMenu("open", false)
openDir(o.id)
}}
>
<div class="flex size-5 shrink-0 items-center justify-center">
<AppIcon id={o.icon} class={openIconSize(o.icon)} />
</div>
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator>
<Icon name="check-small" size="small" class="text-icon-weak" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
)}
</For>
</DropdownMenu.RadioGroup>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={copyPath}>
<DropdownMenu.Item
onSelect={() => {
setMenu("open", false)
copyPath()
}}
>
<div class="flex size-5 shrink-0 items-center justify-center">
<Icon name="copy" size="small" class="text-icon-weak" />
</div>
@@ -409,7 +454,7 @@ export function SessionHeader() {
<Popover
title={language.t("session.share.popover.title")}
description={
shareUrl()
share.shareUrl()
? language.t("session.share.popover.description.shared")
: language.t("session.share.popover.description.unshared")
}
@@ -422,24 +467,24 @@ export function SessionHeader() {
variant: "ghost",
class:
"rounded-md h-[24px] px-3 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active",
classList: { "rounded-r-none": shareUrl() !== undefined },
classList: { "rounded-r-none": share.shareUrl() !== undefined },
style: { scale: 1 },
}}
trigger={language.t("session.share.action.share")}
>
<div class="flex flex-col gap-2">
<Show
when={shareUrl()}
when={share.shareUrl()}
fallback={
<div class="flex">
<Button
size="large"
variant="primary"
class="w-1/2"
onClick={shareSession}
disabled={state.share}
onClick={share.shareSession}
disabled={share.state.share}
>
{state.share
{share.state.share
? language.t("session.share.action.publishing")
: language.t("session.share.action.publish")}
</Button>
@@ -448,7 +493,7 @@ export function SessionHeader() {
>
<div class="flex flex-col gap-2">
<TextField
value={shareUrl() ?? ""}
value={share.shareUrl() ?? ""}
readOnly
copyable
copyKind="link"
@@ -460,10 +505,10 @@ export function SessionHeader() {
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession}
disabled={state.unshare}
onClick={share.unshareSession}
disabled={share.state.unshare}
>
{state.unshare
{share.state.unshare
? language.t("session.share.action.unpublishing")
: language.t("session.share.action.unpublish")}
</Button>
@@ -471,8 +516,8 @@ export function SessionHeader() {
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={state.unshare}
onClick={share.viewShare}
disabled={share.state.unshare}
>
{language.t("session.share.action.view")}
</Button>
@@ -481,10 +526,10 @@ export function SessionHeader() {
</Show>
</div>
</Popover>
<Show when={shareUrl()} fallback={<div aria-hidden="true" />}>
<Show when={share.shareUrl()} fallback={<div aria-hidden="true" />}>
<Tooltip
value={
state.copied
share.state.copied
? language.t("session.share.copy.copied")
: language.t("session.share.copy.copyLink")
}
@@ -492,13 +537,13 @@ export function SessionHeader() {
gutter={8}
>
<IconButton
icon={state.copied ? "check" : "link"}
icon={share.state.copied ? "check" : "link"}
variant="ghost"
class="rounded-l-none h-[24px] border border-border-base bg-surface-panel shadow-none"
onClick={copyLink}
disabled={state.unshare}
onClick={() => share.copyLink((error) => showRequestError(language, error))}
disabled={share.state.unshare}
aria-label={
state.copied
share.state.copied
? language.t("session.share.copy.copied")
: language.t("session.share.copy.copyLink")
}
@@ -507,7 +552,7 @@ export function SessionHeader() {
</Show>
</div>
</Show>
<div class="hidden md:flex items-center gap-3 ml-2 shrink-0">
<div class="flex items-center gap-3 ml-2 shrink-0">
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
@@ -540,7 +585,7 @@ export function SessionHeader() {
</Button>
</TooltipKeybind>
</div>
<div class="hidden md:block shrink-0">
<div class="hidden lg:block shrink-0">
<TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
<Button
variant="ghost"
@@ -570,7 +615,7 @@ export function SessionHeader() {
</Button>
</TooltipKeybind>
</div>
<div class="hidden md:block shrink-0">
<div class="hidden lg:block shrink-0">
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
@@ -8,6 +8,8 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
const MAIN_WORKTREE = "main"
const CREATE_WORKTREE = "create"
const ROOT_CLASS =
"size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]"
interface NewSessionViewProps {
worktree: string
@@ -47,7 +49,7 @@ export function NewSessionView(props: NewSessionViewProps) {
}
return (
<div class="size-full flex flex-col justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto 2xl:max-w-[1000px] px-6 pb-[calc(var(--prompt-height,11.25rem)+64px)]">
<div class={ROOT_CLASS}>
<div class="text-20-medium text-text-weaker">{language.t("command.session.new")}</div>
<div class="flex justify-center items-center gap-3">
<Icon name="folder" size="small" />
@@ -31,8 +31,12 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
const command = useCommand()
const sortable = createSortable(props.tab)
const path = createMemo(() => file.pathFromTab(props.tab))
const content = createMemo(() => {
const value = path()
if (!value) return
return <FileVisual path={value} />
})
return (
// @ts-ignore
<div use:sortable classList={{ "h-full": true, "opacity-0": sortable.isActiveDraggable }}>
<div class="relative h-full">
<Tabs.Trigger
@@ -55,7 +59,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}
>
<Show when={path()}>{(p) => <FileVisual path={p()} />}</Show>
<Show when={content()}>{(value) => value()}</Show>
</Tabs.Trigger>
</div>
</div>
@@ -1,5 +1,5 @@
import type { JSX } from "solid-js"
import { Show } from "solid-js"
import { Show, createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createSortable } from "@thisbeyond/solid-dnd"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -20,6 +20,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
menuPosition: { x: 0, y: 0 },
blurEnabled: false,
})
let input: HTMLInputElement | undefined
let blurFrame: number | undefined
const isDefaultTitle = () => {
const number = props.terminal.titleNumber
@@ -77,13 +79,6 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
setStore("blurEnabled", false)
setStore("title", props.terminal.title)
setStore("editing", true)
setTimeout(() => {
const input = document.getElementById(`terminal-title-input-${props.terminal.id}`) as HTMLInputElement
if (!input) return
input.focus()
input.select()
setTimeout(() => setStore("blurEnabled", true), 100)
}, 10)
}
const save = () => {
@@ -114,9 +109,25 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
setStore("menuOpen", true)
}
createEffect(() => {
if (!store.editing) return
if (!input) return
input.focus()
input.select()
if (blurFrame !== undefined) cancelAnimationFrame(blurFrame)
blurFrame = requestAnimationFrame(() => {
blurFrame = undefined
setStore("blurEnabled", true)
})
})
onCleanup(() => {
if (blurFrame === undefined) return
cancelAnimationFrame(blurFrame)
})
return (
<div
// @ts-ignore
use:sortable
class="outline-none focus:outline-none focus-visible:outline-none"
classList={{
@@ -153,7 +164,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () =>
<Show when={store.editing}>
<div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto">
<input
id={`terminal-title-input-${props.terminal.id}`}
ref={input}
type="text"
value={store.title}
onInput={(e) => setStore("title", e.currentTarget.value)}
@@ -2,6 +2,7 @@ import { Component } from "solid-js"
import { useLanguage } from "@/context/language"
export const SettingsAgents: Component = () => {
// TODO: Replace this placeholder with full agents settings controls.
const language = useLanguage()
return (
@@ -2,6 +2,7 @@ import { Component } from "solid-js"
import { useLanguage } from "@/context/language"
export const SettingsCommands: Component = () => {
// TODO: Replace this placeholder with full commands settings controls.
const language = useLanguage()
return (
+311 -266
View File
@@ -1,4 +1,4 @@
import { Component, Show, createEffect, createMemo, createResource, type JSX } from "solid-js"
import { Component, Show, createMemo, createResource, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
@@ -133,6 +133,288 @@ export const SettingsGeneral: Component = () => {
const soundOptions = [...SOUND_OPTIONS]
const soundSelectProps = (current: () => string, set: (id: string) => void) => ({
options: soundOptions,
current: soundOptions.find((o) => o.id === current()),
value: (o: (typeof soundOptions)[number]) => o.id,
label: (o: (typeof soundOptions)[number]) => language.t(o.label),
onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
playDemoSound(option.src)
},
onSelect: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return
set(option.id)
playDemoSound(option.src)
},
variant: "secondary" as const,
size: "small" as const,
triggerVariant: "settings" as const,
})
const AppearanceSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.general.row.language.title")}
description={language.t("settings.general.row.language.description")}
>
<Select
data-action="settings-language"
options={languageOptions()}
current={languageOptions().find((o) => o.value === language.locale())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && language.setLocale(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.appearance.title")}
description={language.t("settings.general.row.appearance.description")}
>
<Select
data-action="settings-color-scheme"
options={colorSchemeOptions()}
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
onHighlight={(option) => {
if (!option) return
theme.previewColorScheme(option.value)
return () => theme.cancelPreview()
}}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.theme.title")}
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
</>
}
>
<Select
data-action="settings-theme"
options={themeOptions()}
current={themeOptions().find((o) => o.id === theme.themeId())}
value={(o) => o.id}
label={(o) => o.name}
onSelect={(option) => {
if (!option) return
theme.setTheme(option.id)
}}
onHighlight={(option) => {
if (!option) return
theme.previewTheme(option.id)
return () => theme.cancelPreview()
}}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.font.title")}
description={language.t("settings.general.row.font.description")}
>
<Select
data-action="settings-font"
options={fontOptionsList}
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
value={(o) => o.value}
label={(o) => language.t(o.label)}
onSelect={(option) => option && settings.appearance.setFont(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
>
{(option) => (
<span style={{ "font-family": monoFontFamily(option?.value) }}>
{option ? language.t(option.label) : ""}
</span>
)}
</Select>
</SettingsRow>
</div>
</div>
)
const NotificationsSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.general.notifications.agent.title")}
description={language.t("settings.general.notifications.agent.description")}
>
<div data-action="settings-notifications-agent">
<Switch
checked={settings.notifications.agent()}
onChange={(checked) => settings.notifications.setAgent(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.notifications.permissions.title")}
description={language.t("settings.general.notifications.permissions.description")}
>
<div data-action="settings-notifications-permissions">
<Switch
checked={settings.notifications.permissions()}
onChange={(checked) => settings.notifications.setPermissions(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.notifications.errors.title")}
description={language.t("settings.general.notifications.errors.description")}
>
<div data-action="settings-notifications-errors">
<Switch
checked={settings.notifications.errors()}
onChange={(checked) => settings.notifications.setErrors(checked)}
/>
</div>
</SettingsRow>
</div>
</div>
)
const SoundsSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.general.sounds.agent.title")}
description={language.t("settings.general.sounds.agent.description")}
>
<div class="flex items-center gap-2">
<div data-action="settings-sounds-agent-enabled">
<Switch
checked={settings.sounds.agentEnabled()}
onChange={(checked) => settings.sounds.setAgentEnabled(checked)}
/>
</div>
<Select
disabled={!settings.sounds.agentEnabled()}
data-action="settings-sounds-agent"
{...soundSelectProps(
() => settings.sounds.agent(),
(id) => settings.sounds.setAgent(id),
)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.permissions.title")}
description={language.t("settings.general.sounds.permissions.description")}
>
<div class="flex items-center gap-2">
<div data-action="settings-sounds-permissions-enabled">
<Switch
checked={settings.sounds.permissionsEnabled()}
onChange={(checked) => settings.sounds.setPermissionsEnabled(checked)}
/>
</div>
<Select
disabled={!settings.sounds.permissionsEnabled()}
data-action="settings-sounds-permissions"
{...soundSelectProps(
() => settings.sounds.permissions(),
(id) => settings.sounds.setPermissions(id),
)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.errors.title")}
description={language.t("settings.general.sounds.errors.description")}
>
<div class="flex items-center gap-2">
<div data-action="settings-sounds-errors-enabled">
<Switch
checked={settings.sounds.errorsEnabled()}
onChange={(checked) => settings.sounds.setErrorsEnabled(checked)}
/>
</div>
<Select
disabled={!settings.sounds.errorsEnabled()}
data-action="settings-sounds-errors"
{...soundSelectProps(
() => settings.sounds.errors(),
(id) => settings.sounds.setErrors(id),
)}
/>
</div>
</SettingsRow>
</div>
</div>
)
const UpdatesSection = () => (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.updates.row.startup.title")}
description={language.t("settings.updates.row.startup.description")}
>
<div data-action="settings-updates-startup">
<Switch
checked={settings.updates.startup()}
disabled={!platform.checkUpdate}
onChange={(checked) => settings.updates.setStartup(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.releaseNotes.title")}
description={language.t("settings.general.row.releaseNotes.description")}
>
<div data-action="settings-release-notes">
<Switch
checked={settings.general.releaseNotes()}
onChange={(checked) => settings.general.setReleaseNotes(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")}
>
<Button size="small" variant="secondary" disabled={store.checking || !platform.checkUpdate} onClick={check}>
{store.checking
? language.t("settings.updates.action.checking")
: language.t("settings.updates.action.checkNow")}
</Button>
</SettingsRow>
</div>
</div>
)
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
@@ -142,278 +424,41 @@ export const SettingsGeneral: Component = () => {
</div>
<div class="flex flex-col gap-8 w-full">
{/* Appearance Section */}
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.appearance")}</h3>
<AppearanceSection />
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.general.row.language.title")}
description={language.t("settings.general.row.language.description")}
>
<Select
data-action="settings-language"
options={languageOptions()}
current={languageOptions().find((o) => o.value === language.locale())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && language.setLocale(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<NotificationsSection />
<SettingsRow
title={language.t("settings.general.row.appearance.title")}
description={language.t("settings.general.row.appearance.description")}
>
<Select
data-action="settings-color-scheme"
options={colorSchemeOptions()}
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
onHighlight={(option) => {
if (!option) return
theme.previewColorScheme(option.value)
return () => theme.cancelPreview()
}}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<SoundsSection />
<SettingsRow
title={language.t("settings.general.row.theme.title")}
description={
<>
{language.t("settings.general.row.theme.description")}{" "}
<Link href="https://opencode.ai/docs/themes/">{language.t("common.learnMore")}</Link>
</>
}
>
<Select
data-action="settings-theme"
options={themeOptions()}
current={themeOptions().find((o) => o.id === theme.themeId())}
value={(o) => o.id}
label={(o) => o.name}
onSelect={(option) => {
if (!option) return
theme.setTheme(option.id)
}}
onHighlight={(option) => {
if (!option) return
theme.previewTheme(option.id)
return () => theme.cancelPreview()
}}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
{(_) => {
const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.())
const enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest)
<SettingsRow
title={language.t("settings.general.row.font.title")}
description={language.t("settings.general.row.font.description")}
>
<Select
data-action="settings-font"
options={fontOptionsList}
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
value={(o) => o.value}
label={(o) => language.t(o.label)}
onSelect={(option) => option && settings.appearance.setFont(option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
>
{(option) => (
<span style={{ "font-family": monoFontFamily(option?.value) }}>
{option ? language.t(option.label) : ""}
</span>
)}
</Select>
</SettingsRow>
</div>
</div>
return (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.desktop.section.wsl")}</h3>
{/* System notifications Section */}
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.notifications")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.general.notifications.agent.title")}
description={language.t("settings.general.notifications.agent.description")}
>
<div data-action="settings-notifications-agent">
<Switch
checked={settings.notifications.agent()}
onChange={(checked) => settings.notifications.setAgent(checked)}
/>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.desktop.wsl.title")}
description={language.t("settings.desktop.wsl.description")}
>
<div data-action="settings-wsl">
<Switch
checked={enabled() ?? false}
disabled={enabledResource.state === "pending"}
onChange={(checked) => platform.setWslEnabled?.(checked)?.finally(() => actions.refetch())}
/>
</div>
</SettingsRow>
</div>
</div>
</SettingsRow>
)
}}
</Show>
<SettingsRow
title={language.t("settings.general.notifications.permissions.title")}
description={language.t("settings.general.notifications.permissions.description")}
>
<div data-action="settings-notifications-permissions">
<Switch
checked={settings.notifications.permissions()}
onChange={(checked) => settings.notifications.setPermissions(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.notifications.errors.title")}
description={language.t("settings.general.notifications.errors.description")}
>
<div data-action="settings-notifications-errors">
<Switch
checked={settings.notifications.errors()}
onChange={(checked) => settings.notifications.setErrors(checked)}
/>
</div>
</SettingsRow>
</div>
</div>
{/* Sound effects Section */}
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.sounds")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.general.sounds.agent.title")}
description={language.t("settings.general.sounds.agent.description")}
>
<Select
data-action="settings-sounds-agent"
options={soundOptions}
current={soundOptions.find((o) => o.id === settings.sounds.agent())}
value={(o) => o.id}
label={(o) => language.t(o.label)}
onHighlight={(option) => {
if (!option) return
playDemoSound(option.src)
}}
onSelect={(option) => {
if (!option) return
settings.sounds.setAgent(option.id)
playDemoSound(option.src)
}}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.permissions.title")}
description={language.t("settings.general.sounds.permissions.description")}
>
<Select
data-action="settings-sounds-permissions"
options={soundOptions}
current={soundOptions.find((o) => o.id === settings.sounds.permissions())}
value={(o) => o.id}
label={(o) => language.t(o.label)}
onHighlight={(option) => {
if (!option) return
playDemoSound(option.src)
}}
onSelect={(option) => {
if (!option) return
settings.sounds.setPermissions(option.id)
playDemoSound(option.src)
}}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.sounds.errors.title")}
description={language.t("settings.general.sounds.errors.description")}
>
<Select
data-action="settings-sounds-errors"
options={soundOptions}
current={soundOptions.find((o) => o.id === settings.sounds.errors())}
value={(o) => o.id}
label={(o) => language.t(o.label)}
onHighlight={(option) => {
if (!option) return
playDemoSound(option.src)
}}
onSelect={(option) => {
if (!option) return
settings.sounds.setErrors(option.id)
playDemoSound(option.src)
}}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
</div>
</div>
{/* Updates Section */}
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.updates.row.startup.title")}
description={language.t("settings.updates.row.startup.description")}
>
<div data-action="settings-updates-startup">
<Switch
checked={settings.updates.startup()}
disabled={!platform.checkUpdate}
onChange={(checked) => settings.updates.setStartup(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.releaseNotes.title")}
description={language.t("settings.general.row.releaseNotes.description")}
>
<div data-action="settings-release-notes">
<Switch
checked={settings.general.releaseNotes()}
onChange={(checked) => settings.general.setReleaseNotes(checked)}
/>
</div>
</SettingsRow>
<SettingsRow
title={language.t("settings.updates.row.check.title")}
description={language.t("settings.updates.row.check.description")}
>
<Button
size="small"
variant="secondary"
disabled={store.checking || !platform.checkUpdate}
onClick={check}
>
{store.checking
? language.t("settings.updates.action.checking")
: language.t("settings.updates.action.checkNow")}
</Button>
</SettingsRow>
</div>
</div>
<UpdatesSection />
<Show when={linux()}>
{(_) => {
+160 -143
View File
@@ -21,6 +21,9 @@ type KeybindMeta = {
group: KeybindGroup
}
type KeybindMap = Record<string, string | undefined>
type CommandContext = ReturnType<typeof useCommand>
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
type GroupKey =
@@ -107,6 +110,150 @@ function signatures(config: string | undefined) {
return sigs
}
function keybinds(value: unknown): KeybindMap {
if (!value || typeof value !== "object" || Array.isArray(value)) return {}
return value as KeybindMap
}
function listFor(command: CommandContext, map: KeybindMap, palette: string) {
const out = new Map<string, KeybindMeta>()
out.set(PALETTE_ID, { title: palette, group: "General" })
for (const opt of command.catalog) {
if (opt.id.startsWith("suggested.")) continue
out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
}
for (const opt of command.options) {
if (opt.id.startsWith("suggested.")) continue
out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
}
for (const [id, value] of Object.entries(map)) {
if (typeof value !== "string") continue
if (out.has(id)) continue
out.set(id, { title: id, group: groupFor(id) })
}
return out
}
function groupedFor(list: Map<string, KeybindMeta>) {
const out = new Map<KeybindGroup, string[]>()
for (const group of GROUPS) out.set(group, [])
for (const [id, item] of list) {
const ids = out.get(item.group)
if (!ids) continue
ids.push(id)
}
for (const group of GROUPS) {
const ids = out.get(group)
if (!ids) continue
ids.sort((a, b) => (list.get(a)?.title ?? "").localeCompare(list.get(b)?.title ?? ""))
}
return out
}
function filteredFor(
query: string,
list: Map<string, KeybindMeta>,
grouped: Map<KeybindGroup, string[]>,
keybind: (id: string) => string,
) {
const value = query.toLowerCase().trim()
if (!value) return grouped
const out = new Map<KeybindGroup, string[]>()
for (const group of GROUPS) out.set(group, [])
const items = Array.from(list.entries()).map(([id, meta]) => ({
id,
title: meta.title,
group: meta.group,
keybind: keybind(id),
}))
const results = fuzzysort.go(value, items, {
keys: ["title", "keybind"],
threshold: -10000,
})
for (const result of results) {
const ids = out.get(result.obj.group)
if (!ids) continue
ids.push(result.obj.id)
}
return out
}
function useKeyCapture(input: {
active: () => string | null
stop: () => void
set: (id: string, keybind: string) => void
used: () => Map<string, { id: string; title: string }[]>
language: ReturnType<typeof useLanguage>
}) {
onMount(() => {
const handle = (event: KeyboardEvent) => {
const id = input.active()
if (!id) return
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
if (event.key === "Escape") {
input.stop()
return
}
const clear =
(event.key === "Backspace" || event.key === "Delete") &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey
if (clear) {
input.set(id, "none")
input.stop()
return
}
const next = recordKeybind(event)
if (!next) return
const conflicts = new Map<string, string>()
for (const sig of signatures(next)) {
for (const item of input.used().get(sig) ?? []) {
if (item.id === id) continue
conflicts.set(item.id, item.title)
}
}
if (conflicts.size > 0) {
showToast({
title: input.language.t("settings.shortcuts.conflict.title"),
description: input.language.t("settings.shortcuts.conflict.description", {
keybind: formatKeybind(next),
titles: [...conflicts.values()].join(", "),
}),
})
return
}
input.set(id, next)
input.stop()
}
document.addEventListener("keydown", handle, true)
onCleanup(() => document.removeEventListener("keydown", handle, true))
})
}
export const SettingsKeybinds: Component = () => {
const command = useCommand()
const language = useLanguage()
@@ -135,11 +282,9 @@ export const SettingsKeybinds: Component = () => {
command.keybinds(false)
}
const hasOverrides = createMemo(() => {
const keybinds = settings.current.keybinds as Record<string, string | undefined> | undefined
if (!keybinds) return false
return Object.values(keybinds).some((x) => typeof x === "string")
})
const map = createMemo(() => keybinds(settings.current.keybinds))
const hasOverrides = createMemo(() => Object.values(map()).some((x) => typeof x === "string"))
const resetAll = () => {
stop()
@@ -152,88 +297,15 @@ export const SettingsKeybinds: Component = () => {
const list = createMemo(() => {
language.locale()
const out = new Map<string, KeybindMeta>()
out.set(PALETTE_ID, { title: language.t("command.palette"), group: "General" })
for (const opt of command.catalog) {
if (opt.id.startsWith("suggested.")) continue
out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
}
for (const opt of command.options) {
if (opt.id.startsWith("suggested.")) continue
out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
}
const keybinds = settings.current.keybinds as Record<string, string | undefined> | undefined
if (keybinds) {
for (const [id, value] of Object.entries(keybinds)) {
if (typeof value !== "string") continue
if (out.has(id)) continue
out.set(id, { title: id, group: groupFor(id) })
}
}
return out
return listFor(command, map(), language.t("command.palette"))
})
const title = (id: string) => list().get(id)?.title ?? ""
const grouped = createMemo(() => {
const map = list()
const out = new Map<KeybindGroup, string[]>()
for (const group of GROUPS) out.set(group, [])
for (const [id, item] of map) {
const ids = out.get(item.group)
if (!ids) continue
ids.push(id)
}
for (const group of GROUPS) {
const ids = out.get(group)
if (!ids) continue
ids.sort((a, b) => {
const at = map.get(a)?.title ?? ""
const bt = map.get(b)?.title ?? ""
return at.localeCompare(bt)
})
}
return out
})
const grouped = createMemo(() => groupedFor(list()))
const filtered = createMemo(() => {
const query = store.filter.toLowerCase().trim()
if (!query) return grouped()
const map = list()
const out = new Map<KeybindGroup, string[]>()
for (const group of GROUPS) out.set(group, [])
const items = Array.from(map.entries()).map(([id, meta]) => ({
id,
title: meta.title,
group: meta.group,
keybind: command.keybind(id) || "",
}))
const results = fuzzysort.go(query, items, {
keys: ["title", "keybind"],
threshold: -10000,
})
for (const result of results) {
const item = result.obj
const ids = out.get(item.group)
if (!ids) continue
ids.push(item.id)
}
return out
return filteredFor(store.filter, list(), grouped(), (id) => command.keybind(id) || "")
})
const hasResults = createMemo(() => {
@@ -282,69 +354,14 @@ export const SettingsKeybinds: Component = () => {
return map
})
const setKeybind = (id: string, keybind: string) => {
settings.keybinds.set(id, keybind)
}
const setKeybind = (id: string, keybind: string) => settings.keybinds.set(id, keybind)
onMount(() => {
const handle = (event: KeyboardEvent) => {
const id = store.active
if (!id) return
event.preventDefault()
event.stopPropagation()
event.stopImmediatePropagation()
if (event.key === "Escape") {
stop()
return
}
const clear =
(event.key === "Backspace" || event.key === "Delete") &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.shiftKey
if (clear) {
setKeybind(id, "none")
stop()
return
}
const next = recordKeybind(event)
if (!next) return
const map = used()
const conflicts = new Map<string, string>()
for (const sig of signatures(next)) {
const list = map.get(sig) ?? []
for (const item of list) {
if (item.id === id) continue
conflicts.set(item.id, item.title)
}
}
if (conflicts.size > 0) {
showToast({
title: language.t("settings.shortcuts.conflict.title"),
description: language.t("settings.shortcuts.conflict.description", {
keybind: formatKeybind(next),
titles: [...conflicts.values()].join(", "),
}),
})
return
}
setKeybind(id, next)
stop()
}
document.addEventListener("keydown", handle, true)
onCleanup(() => {
document.removeEventListener("keydown", handle, true)
})
useKeyCapture({
active: () => store.active,
stop,
set: setKeybind,
used,
language,
})
onCleanup(() => {
@@ -2,6 +2,7 @@ import { Component } from "solid-js"
import { useLanguage } from "@/context/language"
export const SettingsMcp: Component = () => {
// TODO: Replace this placeholder with full MCP settings controls.
const language = useLanguage()
return (
+21 -14
View File
@@ -12,6 +12,25 @@ import { popularProviders } from "@/hooks/use-providers"
type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
const ListLoadingState: Component<{ label: string }> = (props) => {
return (
<div class="flex flex-col items-center justify-center py-12 text-center">
<span class="text-14-regular text-text-weak">{props.label}</span>
</div>
)
}
const ListEmptyState: Component<{ message: string; filter: string }> = (props) => {
return (
<div class="flex flex-col items-center justify-center py-12 text-center">
<span class="text-14-regular text-text-weak">{props.message}</span>
<Show when={props.filter}>
<span class="text-14-regular text-text-strong mt-1">&quot;{props.filter}&quot;</span>
</Show>
</div>
)
}
export const SettingsModels: Component = () => {
const language = useLanguage()
const models = useModels()
@@ -68,24 +87,12 @@ export const SettingsModels: Component = () => {
<Show
when={!list.grouped.loading}
fallback={
<div class="flex flex-col items-center justify-center py-12 text-center">
<span class="text-14-regular text-text-weak">
{language.t("common.loading")}
{language.t("common.loading.ellipsis")}
</span>
</div>
<ListLoadingState label={`${language.t("common.loading")}${language.t("common.loading.ellipsis")}`} />
}
>
<Show
when={list.flat().length > 0}
fallback={
<div class="flex flex-col items-center justify-center py-12 text-center">
<span class="text-14-regular text-text-weak">{language.t("dialog.model.empty")}</span>
<Show when={list.filter()}>
<span class="text-14-regular text-text-strong mt-1">&quot;{list.filter()}&quot;</span>
</Show>
</div>
}
fallback={<ListEmptyState message={language.t("dialog.model.empty")} filter={list.filter()} />}
>
<For each={list.grouped.latest}>
{(group) => (
@@ -165,12 +165,14 @@ export const SettingsPermissions: Component = () => {
const nextValue =
existing && typeof existing === "object" && !Array.isArray(existing) ? { ...existing, "*": action } : action
globalSync.set("config", "permission", { ...map, [id]: nextValue })
globalSync.updateConfig({ permission: { [id]: nextValue } }).catch((err: unknown) => {
const rollback = (err: unknown) => {
globalSync.set("config", "permission", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("settings.permissions.toast.updateFailed.title"), description: message })
})
}
globalSync.set("config", "permission", { ...map, [id]: nextValue })
globalSync.updateConfig({ permission: { [id]: nextValue } }).catch(rollback)
}
return (
@@ -14,7 +14,17 @@ import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogCustomProvider } from "./dialog-custom-provider"
type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderMeta = { source?: ProviderSource }
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
const PROVIDER_NOTES = [
{ match: (id: string) => id === "opencode", key: "dialog.provider.opencode.note" },
{ match: (id: string) => id === "anthropic", key: "dialog.provider.anthropic.note" },
{ match: (id: string) => id.startsWith("github-copilot"), key: "dialog.provider.copilot.note" },
{ match: (id: string) => id === "openai", key: "dialog.provider.openai.note" },
{ match: (id: string) => id === "google", key: "dialog.provider.google.note" },
{ match: (id: string) => id === "openrouter", key: "dialog.provider.openrouter.note" },
{ match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" },
] as const
export const SettingsProviders: Component = () => {
const dialog = useDialog()
@@ -44,22 +54,28 @@ export const SettingsProviders: Component = () => {
return items
})
const source = (item: unknown) => (item as ProviderMeta).source
const source = (item: ProviderItem): ProviderSource | undefined => {
if (!("source" in item)) return
const value = item.source
if (value === "env" || value === "api" || value === "config" || value === "custom") return value
return
}
const type = (item: unknown) => {
const type = (item: ProviderItem) => {
const current = source(item)
if (current === "env") return language.t("settings.providers.tag.environment")
if (current === "api") return language.t("provider.connect.method.apiKey")
if (current === "config") {
const id = (item as { id?: string }).id
if (id && isConfigCustom(id)) return language.t("settings.providers.tag.custom")
if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom")
return language.t("settings.providers.tag.config")
}
if (current === "custom") return language.t("settings.providers.tag.custom")
return language.t("settings.providers.tag.other")
}
const canDisconnect = (item: unknown) => source(item) !== "env"
const canDisconnect = (item: ProviderItem) => source(item) !== "env"
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const isConfigCustom = (providerID: string) => {
const provider = globalSync.data.config.provider?.[providerID]
@@ -175,40 +191,8 @@ export const SettingsProviders: Component = () => {
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
</div>
<Show when={item.id === "opencode"}>
<span class="text-12-regular text-text-weak pl-8">
{language.t("dialog.provider.opencode.note")}
</span>
</Show>
<Show when={item.id === "anthropic"}>
<span class="text-12-regular text-text-weak pl-8">
{language.t("dialog.provider.anthropic.note")}
</span>
</Show>
<Show when={item.id.startsWith("github-copilot")}>
<span class="text-12-regular text-text-weak pl-8">
{language.t("dialog.provider.copilot.note")}
</span>
</Show>
<Show when={item.id === "openai"}>
<span class="text-12-regular text-text-weak pl-8">
{language.t("dialog.provider.openai.note")}
</span>
</Show>
<Show when={item.id === "google"}>
<span class="text-12-regular text-text-weak pl-8">
{language.t("dialog.provider.google.note")}
</span>
</Show>
<Show when={item.id === "openrouter"}>
<span class="text-12-regular text-text-weak pl-8">
{language.t("dialog.provider.openrouter.note")}
</span>
</Show>
<Show when={item.id === "vercel"}>
<span class="text-12-regular text-text-weak pl-8">
{language.t("dialog.provider.vercel.note")}
</span>
<Show when={note(item.id)}>
{(key) => <span class="text-12-regular text-text-weak pl-8">{language.t(key())}</span>}
</Show>
</div>
<Button
+171 -135
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
import { createEffect, createMemo, createSignal, For, onCleanup, Show, type Accessor, type JSXElement } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -7,16 +7,151 @@ import { Tabs } from "@opencode-ai/ui/tabs"
import { Button } from "@opencode-ai/ui/button"
import { Switch } from "@opencode-ai/ui/switch"
import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { DialogSelectServer } from "./dialog-select-server"
import { showToast } from "@opencode-ai/ui/toast"
import { ServerRow } from "@/components/server/server-row"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
const pollMs = 10_000
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
if (parts.length === 1) return value
return (
<>
{parts[0]}
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
{parts.slice(1).join(file)}
</>
)
}
const listServersByHealth = (
list: string[],
active: string | undefined,
status: Record<string, ServerHealth | undefined>,
) => {
if (!list.length) return list
const order = new Map(list.map((url, index) => [url, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff = rank(status[a]) - rank(status[b])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
}
const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) => {
const [status, setStatus] = createStore({} as Record<string, ServerHealth | undefined>)
createEffect(() => {
const list = servers()
let dead = false
const refresh = async () => {
const results: Record<string, ServerHealth> = {}
await Promise.all(
list.map(async (url) => {
results[url] = await checkServerHealth(url, fetcher)
}),
)
if (dead) return
setStatus(reconcile(results))
}
void refresh()
const id = setInterval(() => void refresh(), pollMs)
onCleanup(() => {
dead = true
clearInterval(id)
})
})
return status
}
const useDefaultServerUrl = (
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
) => {
const [url, setUrl] = createSignal<string | undefined>()
const [tick, setTick] = createSignal(0)
createEffect(() => {
tick()
let dead = false
const result = get?.()
if (!result) {
setUrl(undefined)
onCleanup(() => {
dead = true
})
return
}
if (result instanceof Promise) {
void result.then((next) => {
if (dead) return
setUrl(next ? normalizeServerUrl(next) : undefined)
})
onCleanup(() => {
dead = true
})
return
}
setUrl(normalizeServerUrl(result))
onCleanup(() => {
dead = true
})
})
return { url, refresh: () => setTick((value) => value + 1) }
}
const useMcpToggle = (input: {
sync: ReturnType<typeof useSync>
sdk: ReturnType<typeof useSDK>
language: ReturnType<typeof useLanguage>
}) => {
const [loading, setLoading] = createSignal<string | null>(null)
const toggle = async (name: string) => {
if (loading()) return
setLoading(name)
try {
const status = input.sync.data.mcp[name]
await (status?.status === "connected"
? input.sdk.client.mcp.disconnect({ name })
: input.sdk.client.mcp.connect({ name }))
const result = await input.sdk.client.mcp.status()
if (result.data) input.sync.set("mcp", result.data)
} catch (err) {
showToast({
variant: "error",
title: input.language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
} finally {
setLoading(null)
}
}
return { loading, toggle }
}
export function StatusPopover() {
const sync = useSync()
const sdk = useSDK()
@@ -26,115 +161,35 @@ export function StatusPopover() {
const language = useLanguage()
const navigate = useNavigate()
const [store, setStore] = createStore({
status: {} as Record<string, ServerHealth | undefined>,
loading: null as string | null,
defaultServerUrl: undefined as string | undefined,
})
const fetcher = platform.fetch ?? globalThis.fetch
const servers = createMemo(() => {
const current = server.url
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((x) => x !== current)]
return [current, ...list.filter((item) => item !== current)]
})
const sortedServers = createMemo(() => {
const list = servers()
if (!list.length) return list
const active = server.url
const order = new Map(list.map((url, index) => [url, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff = rank(store.status[a]) - rank(store.status[b])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
async function refreshHealth() {
const results: Record<string, ServerHealth> = {}
await Promise.all(
servers().map(async (url) => {
results[url] = await checkServerHealth(url, fetcher)
}),
)
setStore("status", reconcile(results))
}
createEffect(() => {
servers()
refreshHealth()
const interval = setInterval(refreshHealth, 10_000)
onCleanup(() => clearInterval(interval))
})
const mcpItems = createMemo(() =>
Object.entries(sync.data.mcp ?? {})
.map(([name, status]) => ({ name, status: status.status }))
.sort((a, b) => a.name.localeCompare(b.name)),
)
const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length)
const toggleMcp = async (name: string) => {
if (store.loading) return
setStore("loading", name)
try {
const status = sync.data.mcp[name]
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data)
} catch (err) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
} finally {
setStore("loading", null)
}
}
const health = useServerHealth(servers, fetcher)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health))
const mcp = useMcpToggle({ sync, sdk, language })
const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const lspItems = createMemo(() => sync.data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length)
const plugins = createMemo(() => sync.data.config.plugin ?? [])
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
const overallHealthy = createMemo(() => {
const serverHealthy = server.healthy() === true
const anyMcpIssue = mcpItems().some((m) => m.status !== "connected" && m.status !== "disabled")
const anyMcpIssue = mcpNames().some((name) => {
const status = mcpStatus(name)
return status !== "connected" && status !== "disabled"
})
return serverHealthy && !anyMcpIssue
})
const serverCount = createMemo(() => sortedServers().length)
const refreshDefaultServerUrl = () => {
const result = platform.getDefaultServerUrl?.()
if (!result) {
setStore("defaultServerUrl", undefined)
return
}
if (result instanceof Promise) {
result.then((url) => setStore("defaultServerUrl", url ? normalizeServerUrl(url) : undefined))
return
}
setStore("defaultServerUrl", normalizeServerUrl(result))
}
createEffect(() => {
refreshDefaultServerUrl()
})
return (
<Popover
triggerAs={Button}
@@ -173,7 +228,7 @@ export function StatusPopover() {
>
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
{serverCount() > 0 ? `${serverCount()} ` : ""}
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
{language.t("status.popover.tab.servers")}
</Tabs.Trigger>
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
@@ -195,11 +250,7 @@ export function StatusPopover() {
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={sortedServers()}>
{(url) => {
const isActive = () => url === server.url
const isDefault = () => url === store.defaultServerUrl
const status = () => store.status[url]
const isBlocked = () => status()?.healthy === false
const isBlocked = () => health[url]?.healthy === false
return (
<button
type="button"
@@ -217,13 +268,13 @@ export function StatusPopover() {
>
<ServerRow
url={url}
status={status()}
status={health[url]}
dimmed={isBlocked()}
class="flex items-center gap-2 w-full min-w-0"
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
badge={
<Show when={isDefault()}>
<Show when={url === defaultServer.url()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{language.t("common.default")}
</span>
@@ -231,7 +282,7 @@ export function StatusPopover() {
}
>
<div class="flex-1" />
<Show when={isActive()}>
<Show when={url === server.url}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show>
</ServerRow>
@@ -243,7 +294,7 @@ export function StatusPopover() {
<Button
variant="secondary"
class="mt-3 self-start h-8 px-3 py-1.5"
onClick={() => dialog.show(() => <DialogSelectServer />, refreshDefaultServerUrl)}
onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)}
>
{language.t("status.popover.action.manageServers")}
</Button>
@@ -255,39 +306,40 @@ export function StatusPopover() {
<div class="flex flex-col px-2 pb-2">
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={mcpItems().length > 0}
when={mcpNames().length > 0}
fallback={
<div class="text-14-regular text-text-base text-center my-auto">
{language.t("dialog.mcp.empty")}
</div>
}
>
<For each={mcpItems()}>
{(item) => {
const enabled = () => item.status === "connected"
<For each={mcpNames()}>
{(name) => {
const status = () => mcpStatus(name)
const enabled = () => status() === "connected"
return (
<button
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => toggleMcp(item.name)}
disabled={store.loading === item.name}
onClick={() => mcp.toggle(name)}
disabled={mcp.loading() === name}
>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": item.status === "connected",
"bg-icon-critical-base": item.status === "failed",
"bg-border-weak-base": item.status === "disabled",
"bg-icon-success-base": status() === "connected",
"bg-icon-critical-base": status() === "failed",
"bg-border-weak-base": status() === "disabled",
"bg-icon-warning-base":
item.status === "needs_auth" || item.status === "needs_client_registration",
status() === "needs_auth" || status() === "needs_client_registration",
}}
/>
<span class="text-14-regular text-text-base truncate flex-1">{item.name}</span>
<span class="text-14-regular text-text-base truncate flex-1">{name}</span>
<div onClick={(event) => event.stopPropagation()}>
<Switch
checked={enabled()}
disabled={store.loading === item.name}
onChange={() => toggleMcp(item.name)}
disabled={mcp.loading() === name}
onChange={() => mcp.toggle(name)}
/>
</div>
</button>
@@ -334,23 +386,7 @@ export function StatusPopover() {
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={plugins().length > 0}
fallback={
<div class="text-14-regular text-text-base text-center my-auto">
{(() => {
const value = language.t("dialog.plugins.empty")
const file = "opencode.json"
const parts = value.split(file)
if (parts.length === 1) return value
return (
<>
{parts[0]}
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
{parts.slice(1).join(file)}
</>
)
})()}
</div>
}
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
>
<For each={plugins()}>
{(plugin) => (
+232 -135
View File
@@ -10,6 +10,7 @@ import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@openco
import { useLanguage } from "@/context/language"
import { showToast } from "@opencode-ai/ui/toast"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer"
const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
@@ -56,6 +57,91 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
},
}
const debugTerminal = (...values: unknown[]) => {
if (!import.meta.env.DEV) return
console.debug("[terminal]", ...values)
}
const useTerminalUiBindings = (input: {
container: HTMLDivElement
term: Term
cleanups: VoidFunction[]
handlePointerDown: () => void
handleLinkClick: (event: MouseEvent) => void
}) => {
const handleCopy = (event: ClipboardEvent) => {
const selection = input.term.getSelection()
if (!selection) return
const clipboard = event.clipboardData
if (!clipboard) return
event.preventDefault()
clipboard.setData("text/plain", selection)
}
const handlePaste = (event: ClipboardEvent) => {
const clipboard = event.clipboardData
const text = clipboard?.getData("text/plain") ?? clipboard?.getData("text") ?? ""
if (!text) return
event.preventDefault()
event.stopPropagation()
input.term.paste(text)
}
const handleTextareaFocus = () => {
input.term.options.cursorBlink = true
}
const handleTextareaBlur = () => {
input.term.options.cursorBlink = false
}
input.container.addEventListener("copy", handleCopy, true)
input.cleanups.push(() => input.container.removeEventListener("copy", handleCopy, true))
input.container.addEventListener("paste", handlePaste, true)
input.cleanups.push(() => input.container.removeEventListener("paste", handlePaste, true))
input.container.addEventListener("pointerdown", input.handlePointerDown)
input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown))
input.container.addEventListener("click", input.handleLinkClick, { capture: true })
input.cleanups.push(() => input.container.removeEventListener("click", input.handleLinkClick, { capture: true }))
input.term.textarea?.addEventListener("focus", handleTextareaFocus)
input.term.textarea?.addEventListener("blur", handleTextareaBlur)
input.cleanups.push(() => input.term.textarea?.removeEventListener("focus", handleTextareaFocus))
input.cleanups.push(() => input.term.textarea?.removeEventListener("blur", handleTextareaBlur))
}
const persistTerminal = (input: {
term: Term | undefined
addon: SerializeAddon | undefined
cursor: number
pty: LocalPTY
onCleanup?: (pty: LocalPTY) => void
}) => {
if (!input.addon || !input.onCleanup || !input.term) return
const buffer = (() => {
try {
return input.addon.serialize()
} catch {
debugTerminal("failed to serialize terminal buffer")
return ""
}
})()
input.onCleanup({
...input.pty,
buffer,
cursor: input.cursor,
rows: input.term.rows,
cols: input.term.cols,
scrollY: input.term.getViewportY(),
})
}
export const Terminal = (props: TerminalProps) => {
const platform = usePlatform()
const sdk = useSDK()
@@ -70,13 +156,16 @@ export const Terminal = (props: TerminalProps) => {
let serializeAddon: SerializeAddon
let fitAddon: FitAddon
let handleResize: () => void
let handleTextareaFocus: () => void
let handleTextareaBlur: () => void
let fitFrame: number | undefined
let sizeTimer: ReturnType<typeof setTimeout> | undefined
let pendingSize: { cols: number; rows: number } | undefined
let lastSize: { cols: number; rows: number } | undefined
let disposed = false
const cleanups: VoidFunction[] = []
const start =
typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
let cursor = start ?? 0
let output: ReturnType<typeof terminalWriter> | undefined
const cleanup = () => {
if (!cleanups.length) return
@@ -84,14 +173,25 @@ export const Terminal = (props: TerminalProps) => {
for (const fn of fns) {
try {
fn()
} catch {
// ignore
} catch (err) {
debugTerminal("cleanup failed", err)
}
}
}
const pushSize = (cols: number, rows: number) => {
return sdk.client.pty
.update({
ptyID: local.pty.id,
size: { cols, rows },
})
.catch((err) => {
debugTerminal("failed to sync terminal size", err)
})
}
const getTerminalColors = (): TerminalColors => {
const mode = theme.mode()
const mode = theme.mode() === "dark" ? "dark" : "light"
const fallback = DEFAULT_TERMINAL_COLORS[mode]
const currentTheme = theme.themes()[theme.themeId()]
if (!currentTheme) return fallback
@@ -113,6 +213,43 @@ export const Terminal = (props: TerminalProps) => {
const [terminalColors, setTerminalColors] = createSignal<TerminalColors>(getTerminalColors())
const scheduleFit = () => {
if (disposed) return
if (!fitAddon) return
if (fitFrame !== undefined) return
fitFrame = requestAnimationFrame(() => {
fitFrame = undefined
if (disposed) return
fitAddon.fit()
})
}
const scheduleSize = (cols: number, rows: number) => {
if (disposed) return
if (lastSize?.cols === cols && lastSize?.rows === rows) return
pendingSize = { cols, rows }
if (!lastSize) {
lastSize = pendingSize
void pushSize(cols, rows)
return
}
if (sizeTimer !== undefined) return
sizeTimer = setTimeout(() => {
sizeTimer = undefined
const next = pendingSize
if (!next) return
pendingSize = undefined
if (disposed) return
if (lastSize?.cols === next.cols && lastSize?.rows === next.rows) return
lastSize = next
void pushSize(next.cols, next.rows)
}, 100)
}
createEffect(() => {
const colors = getTerminalColors()
setTerminalColors(colors)
@@ -124,17 +261,28 @@ export const Terminal = (props: TerminalProps) => {
const font = monoFontFamily(settings.appearance.font())
if (!term) return
setOptionIfSupported(term, "fontFamily", font)
scheduleFit()
})
let zoom = platform.webviewZoom?.()
createEffect(() => {
const next = platform.webviewZoom?.()
if (next === undefined) return
if (next === zoom) return
zoom = next
scheduleFit()
})
const focusTerminal = () => {
const t = term
if (!t) return
t.focus()
t.textarea?.focus()
setTimeout(() => t.textarea?.focus(), 0)
}
const handlePointerDown = () => {
const activeElement = document.activeElement
if (activeElement instanceof HTMLElement && activeElement !== container) {
if (activeElement instanceof HTMLElement && activeElement !== container && !container.contains(activeElement)) {
activeElement.blur()
}
focusTerminal()
@@ -166,32 +314,27 @@ export const Terminal = (props: TerminalProps) => {
const once = { value: false }
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
if (window.__OPENCODE__?.serverPassword) {
url.username = "opencode"
url.password = window.__OPENCODE__?.serverPassword
}
const socket = new WebSocket(url)
socket.binaryType = "arraybuffer"
cleanups.push(() => {
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
})
if (disposed) {
cleanup()
return
}
ws = socket
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize =
restore &&
typeof local.pty.cols === "number" &&
Number.isSafeInteger(local.pty.cols) &&
local.pty.cols > 0 &&
typeof local.pty.rows === "number" &&
Number.isSafeInteger(local.pty.rows) &&
local.pty.rows > 0
? { cols: local.pty.cols, rows: local.pty.rows }
: undefined
const t = new mod.Terminal({
cursorBlink: true,
cursorStyle: "bar",
cols: restoreSize?.cols,
rows: restoreSize?.rows,
fontSize: 14,
fontFamily: monoFontFamily(settings.appearance.font()),
allowTransparency: false,
convertEol: true,
convertEol: false,
theme: terminalColors(),
scrollback: 10_000,
ghostty: g,
@@ -203,45 +346,13 @@ export const Terminal = (props: TerminalProps) => {
}
ghostty = g
term = t
const copy = () => {
const selection = t.getSelection()
if (!selection) return false
const body = document.body
if (body) {
const textarea = document.createElement("textarea")
textarea.value = selection
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
body.removeChild(textarea)
if (copied) return true
}
const clipboard = navigator.clipboard
if (clipboard?.writeText) {
clipboard.writeText(selection).catch(() => {})
return true
}
return false
}
output = terminalWriter((data) => t.write(data))
t.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase()
if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "c") {
copy()
return true
}
if (event.metaKey && !event.ctrlKey && !event.altKey && key === "c") {
if (!t.hasSelection()) return true
copy()
document.execCommand("copy")
return true
}
@@ -261,58 +372,20 @@ export const Terminal = (props: TerminalProps) => {
serializeAddon = serializer
t.open(container)
container.addEventListener("pointerdown", handlePointerDown)
cleanups.push(() => container.removeEventListener("pointerdown", handlePointerDown))
container.addEventListener("click", handleLinkClick, { capture: true })
cleanups.push(() => container.removeEventListener("click", handleLinkClick, { capture: true }))
handleTextareaFocus = () => {
t.options.cursorBlink = true
}
handleTextareaBlur = () => {
t.options.cursorBlink = false
}
t.textarea?.addEventListener("focus", handleTextareaFocus)
t.textarea?.addEventListener("blur", handleTextareaBlur)
cleanups.push(() => t.textarea?.removeEventListener("focus", handleTextareaFocus))
cleanups.push(() => t.textarea?.removeEventListener("blur", handleTextareaBlur))
useTerminalUiBindings({ container, term: t, cleanups, handlePointerDown, handleLinkClick })
focusTerminal()
fit.fit()
if (local.pty.buffer) {
t.write(local.pty.buffer, () => {
if (local.pty.scrollY) t.scrollToLine(local.pty.scrollY)
})
if (typeof document !== "undefined" && document.fonts) {
document.fonts.ready.then(scheduleFit)
}
fit.observeResize()
handleResize = () => fit.fit()
window.addEventListener("resize", handleResize)
cleanups.push(() => window.removeEventListener("resize", handleResize))
const onResize = t.onResize(async (size) => {
if (socket.readyState === WebSocket.OPEN) {
await sdk.client.pty
.update({
ptyID: local.pty.id,
size: {
cols: size.cols,
rows: size.rows,
},
})
.catch(() => {})
}
const onResize = t.onResize((size) => {
scheduleSize(size.cols, size.rows)
})
cleanups.push(() => disposeIfDisposable(onResize))
const onData = t.onData((data) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(data)
}
if (ws?.readyState === WebSocket.OPEN) ws.send(data)
})
cleanups.push(() => disposeIfDisposable(onData))
const onKey = t.onKey((key) => {
@@ -321,25 +394,64 @@ export const Terminal = (props: TerminalProps) => {
}
})
cleanups.push(() => disposeIfDisposable(onKey))
const startResize = () => {
fit.observeResize()
handleResize = scheduleFit
window.addEventListener("resize", handleResize)
cleanups.push(() => window.removeEventListener("resize", handleResize))
}
if (restore && restoreSize) {
t.write(restore, () => {
fit.fit()
scheduleSize(t.cols, t.rows)
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
startResize()
})
} else {
fit.fit()
scheduleSize(t.cols, t.rows)
if (restore) {
t.write(restore, () => {
if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
})
}
startResize()
}
// t.onScroll((ydisp) => {
// console.log("Scroll position:", ydisp)
// })
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
if (window.__OPENCODE__?.serverPassword) {
url.username = "opencode"
url.password = window.__OPENCODE__?.serverPassword
}
const socket = new WebSocket(url)
socket.binaryType = "arraybuffer"
ws = socket
cleanups.push(() => {
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
})
if (disposed) {
cleanup()
return
}
const handleOpen = () => {
local.onConnect?.()
sdk.client.pty
.update({
ptyID: local.pty.id,
size: {
cols: t.cols,
rows: t.rows,
},
})
.catch(() => {})
scheduleSize(t.cols, t.rows)
}
socket.addEventListener("open", handleOpen)
cleanups.push(() => socket.removeEventListener("open", handleOpen))
if (socket.readyState === WebSocket.OPEN) handleOpen()
const decoder = new TextDecoder()
const handleMessage = (event: MessageEvent) => {
@@ -355,15 +467,15 @@ export const Terminal = (props: TerminalProps) => {
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
cursor = next
}
} catch {
// ignore
} catch (err) {
debugTerminal("invalid websocket control frame", err)
}
return
}
const data = typeof event.data === "string" ? event.data : ""
if (!data) return
t.write(data)
output?.push(data)
cursor += data.length
}
socket.addEventListener("message", handleMessage)
@@ -406,25 +518,10 @@ export const Terminal = (props: TerminalProps) => {
onCleanup(() => {
disposed = true
const t = term
if (serializeAddon && props.onCleanup && t) {
const buffer = (() => {
try {
return serializeAddon.serialize()
} catch {
return ""
}
})()
props.onCleanup({
...local.pty,
buffer,
cursor,
rows: t.rows,
cols: t.cols,
scrollY: t.getViewportY(),
})
}
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
output?.flush()
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
cleanup()
})
@@ -438,7 +535,7 @@ export const Terminal = (props: TerminalProps) => {
classList={{
...(local.classList ?? {}),
"select-text": true,
"size-full px-6 py-3 font-mono": true,
"size-full px-6 py-3 font-mono relative overflow-hidden": true,
[local.class ?? ""]: !!local.class,
}}
{...others}
+25 -23
View File
@@ -13,6 +13,28 @@ import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { applyPath, backPath, forwardPath } from "./titlebar-history"
type TauriDesktopWindow = {
startDragging?: () => Promise<void>
toggleMaximize?: () => Promise<void>
}
type TauriThemeWindow = {
setTheme?: (theme?: "light" | "dark" | null) => Promise<void>
}
type TauriApi = {
window?: {
getCurrentWindow?: () => TauriDesktopWindow
}
webviewWindow?: {
getCurrentWebviewWindow?: () => TauriThemeWindow
}
}
const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__
const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.()
const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.()
export function Titlebar() {
const layout = useLayout()
const platform = usePlatform()
@@ -82,22 +104,7 @@ export function Titlebar() {
const getWin = () => {
if (platform.platform !== "desktop") return
const tauri = (
window as unknown as {
__TAURI__?: {
window?: {
getCurrentWindow?: () => {
startDragging?: () => Promise<void>
toggleMaximize?: () => Promise<void>
}
}
}
}
).__TAURI__
if (!tauri?.window?.getCurrentWindow) return
return tauri.window.getCurrentWindow()
return currentDesktopWindow()
}
createEffect(() => {
@@ -106,13 +113,8 @@ export function Titlebar() {
const scheme = theme.colorScheme()
const value = scheme === "system" ? null : scheme
const tauri = (window as unknown as { __TAURI__?: { webviewWindow?: { getCurrentWebviewWindow?: () => unknown } } })
.__TAURI__
const get = tauri?.webviewWindow?.getCurrentWebviewWindow
if (!get) return
const win = get() as { setTheme?: (theme?: "light" | "dark" | null) => Promise<void> }
if (!win.setTheme) return
const win = currentThemeWindow()
if (!win?.setTheme) return
void win.setTheme(value).catch(() => undefined)
})

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