Compare commits

..
Author SHA1 Message Date
opencode d2fcdef571 release: v1.1.28
Update Nix Hashes / update-node-modules-hashes (push) Has been cancelled
2026-01-20 22:04:12 +00:00
Adam 8137e4dd9c chore: agents.md 2026-01-20 15:59:23 -06:00
David Hill 7be6671e6e refactor Select component to use settings variant for settings modal styling 2026-01-20 21:53:17 +00:00
David Hill 575cc59b37 fix: increase sidebar icon size by removing 16px constraint 2026-01-20 21:53:17 +00:00
David Hill 4350b8fd6b fix: show View all sessions button for active project and close hovercard on click 2026-01-20 21:53:17 +00:00
David Hill 2111473746 fix: remove close delay on hover cards to stop overlapping 2026-01-20 21:53:17 +00:00
David Hill 8c5c377680 fix: review empty state font size 2026-01-20 21:53:17 +00:00
KennyandGitHub d51089b52f docs(web): add KDCO plugins to ecosystem (#7694) 2026-01-20 15:47:07 -06:00
Trevor WalkerandGitHub 694695050a fix(opencode): preserve tool input from running state for MCP tool results (#9667) 2026-01-20 15:12:15 -06:00
DNGriffinandGitHub 1f3b2b5951 fix(app): Edit-project name race condition (#9551) 2026-01-20 15:10:00 -06:00
David Hill de87694867 fix: resolve Select children type conflict with ButtonProps 2026-01-20 21:09:20 +00:00
David Hill ecd28fd520 fix: prompt agent button style 2026-01-20 21:08:50 +00:00
David Hill bf9047ccd1 fix settings sidebar active tab hover to use consistent background 2026-01-20 21:08:50 +00:00
Adam 96a9744347 fix: type error 2026-01-20 15:08:03 -06:00
Caleb NortonandGitHub c4594c4c1f fix(opencode): relax bun version requirement (#9682) 2026-01-20 14:57:19 -06:00
Caleb NortonandGitHub eea70be21a chore: follow conventional commit in nix CI (#9672) 2026-01-20 14:56:37 -06:00
Idris GadiandGitHub e83c01ad36 fix(tui): prevent sidebar height from overflowing. (#9689) 2026-01-20 14:56:18 -06:00
David Hill 7eb724f4e9 fix: dialog shadow 2026-01-20 20:34:46 +00:00
David Hill 7653e2d4d8 update: add new border and shadow style 2026-01-20 20:34:45 +00:00
David Hill 261b1eca2e update keyboard shortcuts panel to match general settings styling 2026-01-20 20:34:45 +00:00
David Hill dbc15d4816 add color scheme preview on hover in appearance dropdown 2026-01-20 20:34:45 +00:00
David Hill 602b6be4d4 update settings panel padding and make content full width 2026-01-20 20:34:45 +00:00
David Hill 7f4277695d set 32px spacing between main title and group title 2026-01-20 20:34:45 +00:00
David Hill 39afc055bf add fade gradient to settings panel headers 2026-01-20 20:34:45 +00:00
David Hill 73b1bc42f4 increase specificity of select trigger hover/expanded states 2026-01-20 20:34:45 +00:00
David Hill 3eea1d424e set select trigger value font weight to regular 2026-01-20 20:34:45 +00:00
David Hill 1092cf4034 increase gap between label and icon in select trigger to 12px 2026-01-20 20:34:45 +00:00
David Hill 0c270b4743 reset select trigger to default state after selection 2026-01-20 20:34:45 +00:00
David Hill 715860f997 set default select trigger background to transparent 2026-01-20 20:34:45 +00:00
David Hill 26f66b5f5d update: color token 2026-01-20 20:34:45 +00:00
David Hill f250a229c9 update select trigger icon styling and spacing 2026-01-20 20:34:45 +00:00
David Hill c2c2bb1fa9 add 4px left padding to sidebar section title 2026-01-20 20:34:45 +00:00
David Hill 19ac6f1948 set hover background of active sidebar item to surface-raised-base 2026-01-20 20:34:45 +00:00
David Hill 0d9ce6ad7b set settings sidebar width to 200px 2026-01-20 20:34:45 +00:00
David Hill 2b95956132 add x-large dialog size and use it for settings modal 2026-01-20 20:34:45 +00:00
David Hill 36bbe809fa add 4px gutter between select trigger and dropdown 2026-01-20 20:34:45 +00:00
David Hill a8113ee0df update select trigger and dropdown styling 2026-01-20 20:34:45 +00:00
David Hill bcb8d970f1 add selector icon and use it for select dropdown trigger 2026-01-20 20:34:44 +00:00
David Hill c57491ba48 add triggerStyle prop to Select and use it for font selector 2026-01-20 20:34:44 +00:00
David Hill 9ffb7141e5 set select dropdown border-radius to 8px 2026-01-20 20:34:44 +00:00
David Hill f3b0f312bf adjust select dropdown positioning and padding structure 2026-01-20 20:34:44 +00:00
David Hill 09a6107649 set select dropdown min-width to 180px 2026-01-20 20:34:44 +00:00
David Hill af8d91117c update select item styling: 4px radius, default cursor, 8px 2px padding 2026-01-20 20:34:44 +00:00
David Hill 0ffc2c2b39 increase select dropdown padding to 4px 2026-01-20 20:34:44 +00:00
David Hill f9c951aa8b render font options in their respective fonts 2026-01-20 20:34:44 +00:00
David Hill 78bcbda2fa wrap settings row groups with styled container 2026-01-20 20:34:44 +00:00
David Hill 262aca1bca remove border and background from settings panel headers 2026-01-20 20:34:44 +00:00
David Hill 0cbbe5af77 remove subheader from General settings panel 2026-01-20 20:34:44 +00:00
David Hill ecae24f426 use medium font weight for settings tab labels 2026-01-20 20:34:44 +00:00
David Hill 745206ffbb increase gap between icon and label in settings tabs to 12px 2026-01-20 20:34:44 +00:00
David Hill 83557e9b66 add keyboard icon and use it for Shortcuts settings tab 2026-01-20 20:34:44 +00:00
David Hill 1a4abe85e8 add sliders icon and use it for General settings tab 2026-01-20 20:34:44 +00:00
David Hill 175313922b use active background color for selected settings tab 2026-01-20 20:34:44 +00:00
David Hill 74ad6dd4c9 update settings tabs layout and spacing 2026-01-20 20:34:44 +00:00
David Hill a94667e8e7 increase icon letter size to 32px in edit project dialog 2026-01-20 20:34:44 +00:00
David Hill ff8abd8c2b increase session messages popover open delay to 1000ms 2026-01-20 20:34:44 +00:00
Adam 95e9407e63 test(app): fix e2e 2026-01-20 14:02:09 -06:00
Adam 1466b43c5c test(app): windows fixes 2026-01-20 14:02:09 -06:00
Adam f73d7e67d3 test(app): windows fixes 2026-01-20 14:02:09 -06:00
Adam 1ac0980c80 test(app): windows e2e 2026-01-20 14:02:09 -06:00
Adam 1d6f650f53 fix(app): ayu theme colors 2026-01-20 13:59:04 -06:00
Adam 0b9b85ea6e wip(app): ayu colors 2026-01-20 13:59:04 -06:00
Adam 5521d66bb8 wip(app): ayu colors 2026-01-20 13:59:04 -06:00
Rahul A MistryandGitHub 281c9d1870 fix(app): change terminal.new keybind to ctrl+alt+t (#9670) 2026-01-20 13:03:20 -06:00
Rahul A MistryandGitHub 80481c2247 fix(app): cleanup pty.exited event listener on unmount (#9671) 2026-01-20 13:03:01 -06:00
drunkpianoandGitHub 156ce54362 fix(ui): prevent Enter key action during IME composition (#9564) 2026-01-20 13:01:56 -06:00
Aiden Cline 1bc919dc74 ignore: add bun/file io skill just for our repo 2026-01-20 12:37:23 -06:00
Github Action 17438a2e90 Update node_modules hashes 2026-01-20 17:36:35 +00:00
17c4202ea8 fix(opencode): Allow compatible Bun versions in packageManager field (#9597)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-01-20 11:34:00 -06:00
Adam 7170983ef2 fix(app): duplicate session loads 2026-01-20 11:25:58 -06:00
Dax Raad b05d88a730 docs: clarify that malicious config files are not an attack vector 2026-01-20 12:17:58 -05:00
a3a06ffc4f fix(ui): show filename in Edit/Write permission titles (#9662)
Co-authored-by: Claude <noreply@anthropic.com>
2026-01-20 11:14:47 -06:00
Tommy D. RossiandGitHub 68e41a1ee7 fix: pass arguments to commands without explicit placeholders (#9606) 2026-01-20 11:12:43 -06:00
Aiden Cline 5622c53e1f tweak: adjust codex prompt to discourage unnecessary question asking and encourage more autonomy 2026-01-20 11:08:04 -06:00
GitHub Action dfe6ce211d chore: generate 2026-01-20 16:57:52 +00:00
Rahul A MistryandGitHub 8639b0767a feat(app): add tooltips to sidebar new session/workspace buttons (#9652) 2026-01-20 10:57:11 -06:00
Adam 5f67e6fd12 fix(app): don't jump accordion on expand/collapse 2026-01-20 10:54:04 -06:00
Adam 86b2002deb fix: type error 2026-01-20 10:34:10 -06:00
Adam 7f862533d8 fix(app): better pending states for workspace operations 2026-01-20 10:31:57 -06:00
msvechlaandGitHub 8f62d4a5e3 fix(mcp): register OAuth callback before opening browser (#9646) 2026-01-20 10:18:49 -06:00
GitHub Action 733226de9d chore: generate 2026-01-20 16:11:56 +00:00
e8b0a65c63 feat: Support ACP audience by mapping to ignore and synthetic (#9593)
Co-authored-by: noam-v <noam@bespo.ai>
2026-01-20 10:11:02 -06:00
GitHub Action cd2125eecd chore: generate 2026-01-20 16:01:54 +00:00
Adam 8595dae1a4 fix(app): session loading loop 2026-01-20 10:01:04 -06:00
Rahul MishraandGitHub c365f0a7c1 feat: add restart and reload menu items on macOS (#9212) 2026-01-20 09:44:15 -06:00
Rahul A MistryandGitHub 01b12949e3 fix(app): terminal no longer hangs on exit or ctrl + D and closes the pane (#9506) 2026-01-20 09:42:20 -06:00
Adam ac7e674a87 fix(app): broken 2026-01-20 09:05:04 -06:00
GitHub Action 47fa496701 chore: generate 2026-01-20 13:34:20 +00:00
Adam d77cbf9c46 chore: cleanup 2026-01-20 07:33:44 -06:00
Adam 340285575b chore: cleanup 2026-01-20 07:33:44 -06:00
Adam dd5b5f5482 chore: cleanup 2026-01-20 07:33:44 -06:00
Adam 924fc9ed80 wip(app): settings 2026-01-20 07:33:44 -06:00
Adam df094a10ff wip(app): settings 2026-01-20 07:33:44 -06:00
Adam de3641e8eb wip(app): settings 2026-01-20 07:33:44 -06:00
Adam 8bcbfd6396 wip(app): settings 2026-01-20 07:33:44 -06:00
opencode e521fee002 release: v1.1.27
Update Nix Hashes / update-node-modules-hashes (push) Has been cancelled
2026-01-20 12:13:48 +00:00
Adam 04e60f2b3d fix(app): no flash of home page on start 2026-01-20 06:10:53 -06:00
GitHub Action 23d71e125c ignore: update download stats 2026-01-20 2026-01-20 12:05:55 +00:00
GitHub Action 27406cf8ef chore: generate 2026-01-20 11:40:31 +00:00
Adam 0596b02f19 chore: cleanup 2026-01-20 05:39:54 -06:00
Adam 5145b72c4a chore: cleanup 2026-01-20 05:37:15 -06:00
Adam 347cd8ac63 chore: cleanup 2026-01-20 05:35:24 -06:00
Adam b711ca57f2 fix(app): localStorage quota 2026-01-20 05:21:33 -06:00
Adam 353115a895 fix(app): user message expand on click 2026-01-20 05:21:33 -06:00
Adam 5f0372183a fix(app): persist quota 2026-01-20 05:21:32 -06:00
GitHub Action 616329ae97 chore: generate 2026-01-20 06:33:16 +00:00
Aiden Cline 9706aaf552 rm filetime assertions from patch tool 2026-01-20 00:32:29 -06:00
Brendan Allan 8b379329a6 fix(desktop): completely disable pinch to zoom 2026-01-20 14:07:39 +08:00
Craig JellickandGitHub 68d1755a9e fix: add space toggle hint to tool selection prompt (#9535) 2026-01-19 23:38:26 -06:00
Aiden Cline 419004992d chore: remove duplicate prompt file 2026-01-19 23:22:57 -06:00
Aiden Cline 0d49df46ef fix: ensure truncation handling applies to mcp servers too 2026-01-19 23:19:24 -06:00
James MengandGitHub 36f5ba52e9 fix(batch): update batch tool definition to outline correct value for max tool calls (#9517) 2026-01-19 22:15:02 -06:00
GitHub Action 088b537657 chore: generate 2026-01-20 01:42:14 +00:00
FilipandGitHub 4ddfa86e7f fix(app): message list overflow & scrolling (#9530) 2026-01-19 19:41:42 -06:00
David Hill b91b76e9eb add 8px padding to recent sessions popover 2026-01-20 01:41:36 +00:00
David Hill 6ed656a615 remove top padding from edit project dialog form 2026-01-20 01:36:34 +00:00
David Hill 7b336add88 update session messages popover gutter to 28px 2026-01-20 01:21:36 +00:00
David Hill 7f9ffe57f9 update thinking text styling in desktop app 2026-01-20 00:42:55 +00:00
David Hill ad31b555a8 position session messages popover at top 2026-01-20 00:27:03 +00:00
David Hill a05c334702 retain session hover state when popover open and update border radius 2026-01-20 00:24:51 +00:00
David Hill cf284e32aa update session hover popover styling 2026-01-20 00:21:11 +00:00
David Hill 054ccee78d update review session empty state styling 2026-01-20 00:15:13 +00:00
DNGriffinandGitHub bfa986d45e feat(app): Add ability to select project directory text to web (#9344) 2026-01-19 17:38:52 -06:00
Dax Raad aa4b06e165 tui: fix message history cleanup to prevent memory leaks 2026-01-19 18:22:19 -05:00
GitHub Action 2542693f7b chore: generate 2026-01-19 22:13:58 +00:00
Adam bec294b781 fix(app): remove copy button from summary 2026-01-19 16:13:16 -06:00
opencode 1ee8a9c0b2 release: v1.1.26
Update Nix Hashes / update-node-modules-hashes (push) Has been cancelled
2026-01-19 21:55:27 +00:00
Adam 4e04bee0c9 fix(app): favicon 2026-01-19 15:46:04 -06:00
SpoonandGitHub 673e79f457 tweak(batch): up restrictive max batch tool from 10 to 25 (#9275) 2026-01-19 15:44:58 -06:00
Adam 79ae749ed8 fix(app): don't change resize handle on hover 2026-01-19 15:28:37 -06:00
FilipandGitHub d605a78a05 fix(app): change keybind for cycling thinking effort (#9508) 2026-01-19 15:15:43 -06:00
GitHub Action 69b3b35ea5 chore: generate 2026-01-19 21:00:39 +00:00
Adam 3173ba1288 fix(app): fade under sticky elements 2026-01-19 14:59:50 -06:00
Adam a4d1824412 fix(app): no more favicons 2026-01-19 14:59:47 -06:00
Adam cac35bc52d fix(app): global terminal/review pane toggles 2026-01-19 14:59:46 -06:00
Adam ecc51ddb4e fix(app): hash nav 2026-01-19 14:59:46 -06:00
Aiden Cline 769c97af08 chore: rm double conditional 2026-01-19 14:49:51 -06:00
GitHub Action e29120317f chore: generate 2026-01-19 20:47:09 +00:00
Ronan KearnsandGitHub 88c5a7fe9e fix(tui): clarify resume session tip (#9490) 2026-01-19 14:46:32 -06:00
Joseph CampuzanoandGitHub 091e88c1e1 fix(opencode): sets input mode based on whether mouse vs keyboard is in use to prevent mouse events firing (#9449) 2026-01-19 14:46:17 -06:00
FilipandGitHub d19e76d96c fix: keyboard nav when mouse hovered over list (#9500) 2026-01-19 14:43:32 -06:00
FilipandGitHub c3393ecc6c fix(app): give feedback when trying to paste a unsupported filetype (#9452) 2026-01-19 14:16:25 -06:00
Ryan VogelandGitHub 889c60d63b fix(web): rename favicons to v2 for cache busting (#9492) 2026-01-19 15:04:59 -05:00
Ariane EmoryandGitHub c47699536f fix: Don't unnecessarily wrap lines and introduce an unneeded empty line (resolves #9489) (#9488) 2026-01-19 13:56:24 -06:00
143 changed files with 3470 additions and 2216 deletions
+66 -26
View File
@@ -8,7 +8,29 @@ on:
workflow_dispatch:
jobs:
test:
runs-on: blacksmith-4vcpu-ubuntu-2404
name: test (${{ matrix.settings.name }})
strategy:
fail-fast: false
matrix:
settings:
- name: linux
host: blacksmith-4vcpu-ubuntu-2404
playwright: bunx playwright install --with-deps
workdir: .
command: |
git config --global user.email "bot@opencode.ai"
git config --global user.name "opencode"
bun turbo typecheck
bun turbo test
- name: windows
host: windows-latest
playwright: bunx playwright install
workdir: packages/app
command: bun test:e2e
runs-on: ${{ matrix.settings.host }}
defaults:
run:
shell: bash
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -20,42 +42,63 @@ jobs:
- name: Install Playwright browsers
working-directory: packages/app
run: bunx playwright install --with-deps
run: ${{ matrix.settings.playwright }}
- name: Set OS-specific paths
run: |
if [ "${{ runner.os }}" = "Windows" ]; then
printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}\\opencode-e2e" >> "$GITHUB_ENV"
printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}\\opencode-e2e\\home" >> "$GITHUB_ENV"
printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}\\opencode-e2e\\share" >> "$GITHUB_ENV"
printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}\\opencode-e2e\\cache" >> "$GITHUB_ENV"
printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}\\opencode-e2e\\config" >> "$GITHUB_ENV"
printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}\\opencode-e2e\\state" >> "$GITHUB_ENV"
printf '%s\n' "MODELS_DEV_API_JSON=${{ github.workspace }}\\packages\\opencode\\test\\tool\\fixtures\\models-api.json" >> "$GITHUB_ENV"
else
printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}/opencode-e2e" >> "$GITHUB_ENV"
printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}/opencode-e2e/home" >> "$GITHUB_ENV"
printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}/opencode-e2e/share" >> "$GITHUB_ENV"
printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}/opencode-e2e/cache" >> "$GITHUB_ENV"
printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}/opencode-e2e/config" >> "$GITHUB_ENV"
printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}/opencode-e2e/state" >> "$GITHUB_ENV"
printf '%s\n' "MODELS_DEV_API_JSON=${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json" >> "$GITHUB_ENV"
fi
- name: Seed opencode data
working-directory: packages/opencode
run: bun script/seed-e2e.ts
env:
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json
MODELS_DEV_API_JSON: ${{ env.MODELS_DEV_API_JSON }}
OPENCODE_DISABLE_MODELS_FETCH: "true"
OPENCODE_DISABLE_SHARE: "true"
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home
XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share
XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache
XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config
XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state
OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
OPENCODE_E2E_PROJECT_DIR: ${{ github.workspace }}
OPENCODE_E2E_SESSION_TITLE: "E2E Session"
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e"
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano"
- name: Run opencode server
run: bun run dev -- --print-logs --log-level WARN serve --port 4096 --hostname 0.0.0.0 &
working-directory: packages/opencode
run: bun dev -- --print-logs --log-level WARN serve --port 4096 --hostname 0.0.0.0 &
env:
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json
MODELS_DEV_API_JSON: ${{ env.MODELS_DEV_API_JSON }}
OPENCODE_DISABLE_MODELS_FETCH: "true"
OPENCODE_DISABLE_SHARE: "true"
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home
XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share
XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache
XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config
XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state
OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
OPENCODE_CLIENT: "app"
- name: Wait for opencode server
@@ -67,24 +110,21 @@ jobs:
exit 1
- name: run
run: |
git config --global user.email "bot@opencode.ai"
git config --global user.name "opencode"
bun turbo typecheck
bun turbo test
working-directory: ${{ matrix.settings.workdir }}
run: ${{ matrix.settings.command }}
env:
CI: true
MODELS_DEV_API_JSON: ${{ github.workspace }}/packages/opencode/test/tool/fixtures/models-api.json
MODELS_DEV_API_JSON: ${{ env.MODELS_DEV_API_JSON }}
OPENCODE_DISABLE_MODELS_FETCH: "true"
OPENCODE_DISABLE_SHARE: "true"
OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
OPENCODE_TEST_HOME: ${{ runner.temp }}/opencode-e2e/home
XDG_DATA_HOME: ${{ runner.temp }}/opencode-e2e/share
XDG_CACHE_HOME: ${{ runner.temp }}/opencode-e2e/cache
XDG_CONFIG_HOME: ${{ runner.temp }}/opencode-e2e/config
XDG_STATE_HOME: ${{ runner.temp }}/opencode-e2e/state
OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
PLAYWRIGHT_SERVER_HOST: "localhost"
PLAYWRIGHT_SERVER_PORT: "4096"
VITE_OPENCODE_SERVER_HOST: "localhost"
+1 -1
View File
@@ -128,7 +128,7 @@ jobs:
echo "Changes detected:"
echo "$STATUS"
git add "${FILES[@]}"
git commit -m "Update $TITLE"
git commit -m "chore: update nix node_modules hashes"
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
git pull --rebase --autostash origin "$BRANCH"
+39
View File
@@ -0,0 +1,39 @@
---
name: bun-file-io
description: Use this when you are working on file operations like reading, writing, scanning, or deleting files. It summarizes the preferred file APIs and patterns used in this repo. It also notes when to use filesystem helpers for directories.
---
## Use this when
- Editing file I/O or scans in `packages/opencode`
- Handling directory operations or external tools
## Bun file APIs (from Bun docs)
- `Bun.file(path)` is lazy; call `text`, `json`, `stream`, `arrayBuffer`, `bytes`, `exists` to read.
- Metadata: `file.size`, `file.type`, `file.name`.
- `Bun.write(dest, input)` writes strings, buffers, Blobs, Responses, or files.
- `Bun.file(...).delete()` deletes a file.
- `file.writer()` returns a FileSink for incremental writes.
- `Bun.Glob` + `Array.fromAsync(glob.scan({ cwd, absolute, onlyFiles, dot }))` for scans.
- Use `Bun.which` to find a binary, then `Bun.spawn` to run it.
- `Bun.readableStreamToText/Bytes/JSON` for stream output.
## When to use node:fs
- Use `node:fs/promises` for directories (`mkdir`, `readdir`, recursive operations).
## Repo patterns
- Prefer Bun APIs over Node `fs` for file access.
- Check `Bun.file(...).exists()` before reading.
- For binary/large files use `arrayBuffer()` and MIME checks via `file.type`.
- Use `Bun.Glob` + `Array.fromAsync` for scans.
- Decode tool stderr with `Bun.readableStreamToText`.
- For large writes, use `Bun.write(Bun.file(path), text)`.
## Quick checklist
- Use Bun APIs first.
- Use `path.join`/`path.resolve` for paths.
- Prefer promise `.catch(...)` over `try/catch` when possible.
-6
View File
@@ -1,6 +0,0 @@
---
name: test-skill
description: use this when asked to test skill
---
woah this is a test skill
+1
View File
@@ -24,6 +24,7 @@ Server mode is opt-in only. When enabled, set `OPENCODE_SERVER_PASSWORD` to requ
| **Sandbox escapes** | The permission system is not a sandbox (see above) |
| **LLM provider data handling** | Data sent to your configured LLM provider is governed by their policies |
| **MCP server behavior** | External MCP servers you configure are outside our trust boundary |
| **Malicious config files** | Users control their own config; modifying it is not an attack vector |
---
+1
View File
@@ -205,3 +205,4 @@
| 2026-01-17 | 4,389,558 (+268,008) | 1,805,315 (+50,897) | 6,194,873 (+318,905) |
| 2026-01-18 | 4,627,623 (+238,065) | 1,839,171 (+33,856) | 6,466,794 (+271,921) |
| 2026-01-19 | 4,861,108 (+233,485) | 1,863,112 (+23,941) | 6,724,220 (+257,426) |
| 2026-01-20 | 5,128,999 (+267,891) | 1,903,665 (+40,553) | 7,032,664 (+308,444) |
+20 -15
View File
@@ -16,13 +16,14 @@
"@tsconfig/bun": "catalog:",
"husky": "9.1.7",
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "3.17.23",
"turbo": "2.5.6",
},
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.25",
"version": "1.1.28",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -71,7 +72,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.25",
"version": "1.1.28",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -105,7 +106,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.1.25",
"version": "1.1.28",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -132,7 +133,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.1.25",
"version": "1.1.28",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -156,7 +157,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.1.25",
"version": "1.1.28",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -180,7 +181,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.1.25",
"version": "1.1.28",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -209,7 +210,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.25",
"version": "1.1.28",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -238,7 +239,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.25",
"version": "1.1.28",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -254,7 +255,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.25",
"version": "1.1.28",
"bin": {
"opencode": "./bin/opencode",
},
@@ -358,7 +359,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.25",
"version": "1.1.28",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -374,11 +375,12 @@
"name": "@opencode-ai/script",
"devDependencies": {
"@types/bun": "catalog:",
"@types/semver": "catalog:",
},
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.25",
"version": "1.1.28",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.4",
"@tsconfig/node22": "catalog:",
@@ -389,7 +391,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.25",
"version": "1.1.28",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -402,7 +404,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.25",
"version": "1.1.28",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -443,7 +445,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.25",
"version": "1.1.28",
"dependencies": {
"zod": "catalog:",
},
@@ -454,7 +456,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.25",
"version": "1.1.28",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -514,6 +516,7 @@
"@types/bun": "1.3.5",
"@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.119",
"diff": "8.0.2",
@@ -1849,6 +1852,8 @@
"@types/scheduler": ["@types/scheduler@0.26.0", "", {}, "sha512-WFHp9YUJQ6CKshqoC37iOlHnQSmxNc795UhB26CyBBttrN9svdIrUjl/NjnNmfcwtncN0h/0PPAFWv9ovP8mLA=="],
"@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="],
"@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="],
"@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-80+b7FwUy4mRWTzEjPrBWuR5Um67I1Rn4U/n/s/lBjs=",
"aarch64-linux": "sha256-xH/Grwh3b+HWsUkKN8LMcyMaMcmnIJYlgp38WJCat5E=",
"aarch64-darwin": "sha256-Izv6PE9gNaeYYfcqDwjTU/WYtD1y+j65annwvLzkMD8=",
"x86_64-darwin": "sha256-EG1Z0uAeyFiOeVsv0Sz1sa8/mdXuw/uvbYYrkFR3EAg="
"x86_64-linux": "sha256-xVA4r7Qugw0TSx5wiTI5al93FI4D5LlvQo2ab3cUlmE=",
"aarch64-linux": "sha256-EV0U/mXlrnEyCryL9rLlOZvMn6U0+BSgPhTIudVeqTo=",
"aarch64-darwin": "sha256-zQvdRyNEHrpJsQMj8PZH0Ub21EREmDetVaJ0yBCgDlE=",
"x86_64-darwin": "sha256-Tt5k5KBnrsNVIqPET7OFzClerjdR68XYstyCj3KpvdI="
}
}
+2
View File
@@ -28,6 +28,7 @@
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
"@types/node": "22.13.9",
"@types/semver": "7.7.1",
"@tsconfig/node22": "22.0.2",
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
@@ -66,6 +67,7 @@
"@tsconfig/bun": "catalog:",
"husky": "9.1.7",
"prettier": "3.6.2",
"semver": "^7.6.0",
"sst": "3.17.23",
"turbo": "2.5.6"
},
+11 -2
View File
@@ -1,7 +1,5 @@
## Debugging
- To test the opencode app, use the playwright MCP server, the app is already
running at http://localhost:3000
- NEVER try to restart the app, or the server process, EVER.
## SolidJS
@@ -11,3 +9,14 @@
## Tool Calling
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
## Browser Automation
Use `agent-browser` for web automation. Run `agent-browser --help` for all commands.
Core workflow:
1. `agent-browser open <url>` - Navigate to page
2. `agent-browser snapshot -i` - Get interactive elements with refs (@e1, @e2)
3. `agent-browser click @e1` / `fill @e2 "text"` - Interact using refs
4. Re-snapshot after page changes
+7 -5
View File
@@ -31,18 +31,20 @@ Your app is ready to be deployed!
## E2E Testing
The Playwright runner expects the app already running at `http://localhost:3000`.
Playwright starts the Vite dev server automatically via `webServer`, and UI tests need an opencode backend (defaults to `localhost:4096`).
Use the local runner to create a temp sandbox, seed data, and run the tests.
```bash
bun add -D @playwright/test
bunx playwright install
bun run test:e2e
bun run test:e2e:local
bun run test:e2e:local -- --grep "settings"
```
Environment options:
- `PLAYWRIGHT_BASE_URL` (default: `http://localhost:3000`)
- `PLAYWRIGHT_PORT` (default: `3000`)
- `PLAYWRIGHT_SERVER_HOST` / `PLAYWRIGHT_SERVER_PORT` (backend address, default: `localhost:4096`)
- `PLAYWRIGHT_PORT` (Vite dev server port, default: `3000`)
- `PLAYWRIGHT_BASE_URL` (override base URL, default: `http://localhost:<PLAYWRIGHT_PORT>`)
## Deployment
+62
View File
@@ -0,0 +1,62 @@
import { test, expect } from "./fixtures"
import { promptSelector } from "./utils"
function sessionIDFromUrl(url: string) {
const match = /\/session\/([^/?#]+)/.exec(url)
return match?.[1]
}
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
const pageErrors: string[] = []
const onPageError = (err: Error) => {
pageErrors.push(err.message)
}
page.on("pageerror", onPageError)
await gotoSession()
const token = `E2E_OK_${Date.now()}`
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type(`Reply with exactly: ${token}`)
await page.keyboard.press("Enter")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = (() => {
const id = sessionIDFromUrl(page.url())
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
return id
})()
try {
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)
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)
}
if (pageErrors.length > 0) {
throw new Error(`Page error(s):\n${pageErrors.join("\n")}`)
}
})
+4 -4
View File
@@ -4,10 +4,10 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenCode</title>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" href="/favicon-96x96-v2.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon-v2.svg" />
<link rel="shortcut icon" href="/favicon-v2.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v2.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#F8F7F7" />
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.25",
"version": "1.1.28",
"description": "",
"type": "module",
"exports": {
@@ -15,6 +15,7 @@
"serve": "vite preview",
"test": "playwright test",
"test:e2e": "playwright test",
"test:e2e:local": "bun script/e2e-local.ts",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report e2e/playwright-report"
},
+1
View File
@@ -0,0 +1 @@
../../ui/src/assets/favicon/apple-touch-icon-v2.png
+1
View File
@@ -0,0 +1 @@
../../ui/src/assets/favicon/favicon-96x96-v2.png
+1
View File
@@ -0,0 +1 @@
../../ui/src/assets/favicon/favicon-v2.ico
+1
View File
@@ -0,0 +1 @@
../../ui/src/assets/favicon/favicon-v2.svg
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

+130
View File
@@ -0,0 +1,130 @@
import fs from "node:fs/promises"
import net from "node:net"
import os from "node:os"
import path from "node:path"
async function freePort() {
return await new Promise<number>((resolve, reject) => {
const server = net.createServer()
server.once("error", reject)
server.listen(0, () => {
const address = server.address()
if (!address || typeof address === "string") {
server.close(() => reject(new Error("Failed to acquire a free port")))
return
}
server.close((err) => {
if (err) {
reject(err)
return
}
resolve(address.port)
})
})
})
}
async function waitForHealth(url: string) {
const timeout = Date.now() + 60_000
while (Date.now() < timeout) {
const ok = await fetch(url)
.then((r) => r.ok)
.catch(() => false)
if (ok) return
await new Promise((r) => setTimeout(r, 250))
}
throw new Error(`Timed out waiting for server health: ${url}`)
}
const appDir = process.cwd()
const repoDir = path.resolve(appDir, "../..")
const opencodeDir = path.join(repoDir, "packages", "opencode")
const modelsJson = path.join(opencodeDir, "test", "tool", "fixtures", "models-api.json")
const extraArgs = (() => {
const args = process.argv.slice(2)
if (args[0] === "--") return args.slice(1)
return args
})()
const [serverPort, webPort] = await Promise.all([freePort(), freePort()])
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-"))
const serverEnv = {
...process.env,
MODELS_DEV_API_JSON: modelsJson,
OPENCODE_DISABLE_MODELS_FETCH: "true",
OPENCODE_DISABLE_SHARE: "true",
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
OPENCODE_TEST_HOME: path.join(sandbox, "home"),
XDG_DATA_HOME: path.join(sandbox, "share"),
XDG_CACHE_HOME: path.join(sandbox, "cache"),
XDG_CONFIG_HOME: path.join(sandbox, "config"),
XDG_STATE_HOME: path.join(sandbox, "state"),
OPENCODE_E2E_PROJECT_DIR: repoDir,
OPENCODE_E2E_SESSION_TITLE: "E2E Session",
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano",
OPENCODE_CLIENT: "app",
} satisfies Record<string, string>
const runnerEnv = {
...process.env,
PLAYWRIGHT_SERVER_HOST: "localhost",
PLAYWRIGHT_SERVER_PORT: String(serverPort),
VITE_OPENCODE_SERVER_HOST: "localhost",
VITE_OPENCODE_SERVER_PORT: String(serverPort),
PLAYWRIGHT_PORT: String(webPort),
} satisfies Record<string, string>
const seed = Bun.spawn(["bun", "script/seed-e2e.ts"], {
cwd: opencodeDir,
env: serverEnv,
stdout: "inherit",
stderr: "inherit",
})
const seedExit = await seed.exited
if (seedExit !== 0) {
process.exit(seedExit)
}
const server = Bun.spawn(
[
"bun",
"dev",
"--",
"--print-logs",
"--log-level",
"WARN",
"serve",
"--port",
String(serverPort),
"--hostname",
"127.0.0.1",
],
{
cwd: opencodeDir,
env: serverEnv,
stdout: "inherit",
stderr: "inherit",
},
)
try {
await waitForHealth(`http://localhost:${serverPort}/global/health`)
const runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], {
cwd: appDir,
env: runnerEnv,
stdout: "inherit",
stderr: "inherit",
})
process.exitCode = await runner.exited
} finally {
server.kill()
}
+24 -19
View File
@@ -14,6 +14,7 @@ import { PermissionProvider } from "@/context/permission"
import { LayoutProvider } from "@/context/layout"
import { GlobalSDKProvider } from "@/context/global-sdk"
import { ServerProvider, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal"
import { PromptProvider } from "@/context/prompt"
import { FileProvider } from "@/context/file"
@@ -82,15 +83,17 @@ export function AppInterface(props: { defaultUrl?: string }) {
<GlobalSyncProvider>
<Router
root={(props) => (
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
<CommandProvider>
<Layout>{props.children}</Layout>
</CommandProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
<SettingsProvider>
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
<CommandProvider>
<Layout>{props.children}</Layout>
</CommandProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
</SettingsProvider>
)}
>
<Route
@@ -105,16 +108,18 @@ export function AppInterface(props: { defaultUrl?: string }) {
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={() => (
<TerminalProvider>
<FileProvider>
<PromptProvider>
<Suspense fallback={<Loading />}>
<Session />
</Suspense>
</PromptProvider>
</FileProvider>
</TerminalProvider>
component={(p) => (
<Show when={p.params.id ?? "new"} keyed>
<TerminalProvider>
<FileProvider>
<PromptProvider>
<Suspense fallback={<Loading />}>
<Session />
</Suspense>
</PromptProvider>
</FileProvider>
</TerminalProvider>
</Show>
)}
/>
</Route>
@@ -22,7 +22,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
const [store, setStore] = createStore({
name: defaultName(),
color: props.project.icon?.color || "pink",
iconUrl: props.project.icon?.url || "",
iconUrl: props.project.icon?.override || "",
saving: false,
})
@@ -74,7 +74,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
await globalSDK.client.project.update({
projectID: props.project.id,
name,
icon: { color: store.color, url: store.iconUrl },
icon: { color: store.color, override: store.iconUrl },
})
setStore("saving", false)
dialog.close()
@@ -82,7 +82,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
return (
<Dialog title="Edit project" class="w-full max-w-[480px] mx-auto">
<form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6">
<form onSubmit={handleSubmit} class="flex flex-col gap-6 p-6 pt-0">
<div class="flex flex-col gap-4">
<TextField
autofocus
@@ -123,6 +123,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
fallback={store.name || defaultName()}
{...getAvatarColors(store.color)}
class="size-full"
style={{ "font-size": "32px" }}
/>
</div>
}
@@ -1,189 +0,0 @@
import { createSignal, createEffect, onMount, onCleanup } 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"
import { markReleaseNotesSeen } from "@/lib/release-notes"
export interface ReleaseFeature {
title: string
description: string
tag?: string
media?: {
type: "image" | "video"
src: string
alt?: string
}
}
export interface ReleaseNote {
version: string
features: ReleaseFeature[]
}
// Current release notes - update this with each release
export const CURRENT_RELEASE: ReleaseNote = {
version: "1.0.0",
features: [
{
title: "Cleaner tab experience",
description: "Chat is now fixed to the side of your tabs, and review is now available as a dedicated tab. ",
tag: "New",
media: {
type: "video",
src: "/release/release-example.mp4",
alt: "Cleaner tab experience",
},
},
{
title: "Share with control",
description: "Keep your sessions private by default, or publish them to the web with a shareable URL.",
tag: "New",
media: {
type: "image",
src: "/release/release-share.png",
alt: "Share with control",
},
},
{
title: "Improved attachment management",
description: "Upload and manage attachments more easily, to help build and maintain context.",
tag: "New",
media: {
type: "video",
src: "/release/release-example.mp4",
alt: "Improved attachment management",
},
},
],
}
export function DialogReleaseNotes(props: { release?: ReleaseNote }) {
const dialog = useDialog()
const release = props.release ?? CURRENT_RELEASE
const [index, setIndex] = createSignal(0)
const feature = () => release.features[index()]
const total = release.features.length
const isFirst = () => index() === 0
const isLast = () => index() === total - 1
function handleNext() {
if (!isLast()) setIndex(index() + 1)
}
function handleBack() {
if (!isFirst()) setIndex(index() - 1)
}
function handleClose() {
markReleaseNotesSeen()
dialog.close()
}
let focusTrap: HTMLDivElement | undefined
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "ArrowLeft" && !isFirst()) {
e.preventDefault()
setIndex(index() - 1)
}
if (e.key === "ArrowRight" && !isLast()) {
e.preventDefault()
setIndex(index() + 1)
}
}
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 class="dialog-release-notes">
{/* Hidden element to capture initial focus and handle escape */}
<div ref={focusTrap} tabindex="0" class="absolute opacity-0 pointer-events-none" />
{/* Left side - Text content */}
<div class="flex flex-col flex-1 min-w-0 p-8">
{/* Top section - feature content (fixed position from top) */}
<div class="flex flex-col gap-2 pt-22">
<div class="flex items-center gap-2">
<h1 class="text-16-medium text-text-strong">{feature().title}</h1>
{feature().tag && (
<span
class="text-12-medium text-text-weak px-1.5 py-0.5 bg-surface-base rounded-sm border border-border-weak-base"
style={{ "border-width": "0.5px" }}
>
{feature().tag}
</span>
)}
</div>
<p class="text-14-regular text-text-base">{feature().description}</p>
</div>
{/* Spacer to push buttons to bottom */}
<div class="flex-1" />
{/* Bottom section - buttons and indicators (fixed position) */}
<div class="flex flex-col gap-12">
<div class="flex items-center gap-3">
{isLast() ? (
<Button variant="primary" size="large" onClick={handleClose}>
Get started
</Button>
) : (
<Button variant="secondary" size="large" onClick={handleNext}>
Next
</Button>
)}
</div>
{total > 1 && (
<div class="flex items-center gap-1.5 -my-2.5">
{release.features.map((_, i) => (
<button
type="button"
class="h-6 flex items-center cursor-pointer bg-transparent border-none p-0 transition-all duration-200"
classList={{
"w-8": i === index(),
"w-3": i !== index(),
}}
onClick={() => setIndex(i)}
>
<div
class="w-full h-0.5 rounded-[1px] transition-colors duration-200"
classList={{
"bg-icon-strong-base": i === index(),
"bg-icon-weak-base": i !== index(),
}}
/>
</button>
))}
</div>
)}
</div>
</div>
{/* Right side - Media content (edge to edge) */}
{feature().media && (
<div class="flex-1 min-w-0 bg-surface-base overflow-hidden rounded-r-xl">
{feature().media!.type === "image" ? (
<img
src={feature().media!.src}
alt={feature().media!.alt ?? "Release preview"}
class="w-full h-full object-cover"
/>
) : (
<video src={feature().media!.src} autoplay loop muted playsinline class="w-full h-full object-cover" />
)}
</div>
)}
</Dialog>
)
}
@@ -34,7 +34,14 @@ export function DialogSelectFile() {
const view = createMemo(() => layout.view(sessionKey()))
const state = { cleanup: undefined as (() => void) | void, committed: false }
const [grouped, setGrouped] = createSignal(false)
const common = ["session.new", "session.previous", "session.next", "terminal.toggle", "review.toggle"]
const common = [
"session.new",
"workspace.new",
"session.previous",
"session.next",
"terminal.toggle",
"review.toggle",
]
const limit = 5
const allowed = createMemo(() =>
@@ -0,0 +1,94 @@
import { Component } from "solid-js"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { SettingsGeneral } from "./settings-general"
import { SettingsKeybinds } from "./settings-keybinds"
import { SettingsPermissions } from "./settings-permissions"
import { SettingsProviders } from "./settings-providers"
import { SettingsModels } from "./settings-models"
import { SettingsAgents } from "./settings-agents"
import { SettingsCommands } from "./settings-commands"
import { SettingsMcp } from "./settings-mcp"
export const DialogSettings: Component = () => {
return (
<Dialog size="x-large">
<Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
<Tabs.List>
<div
style={{
display: "flex",
"flex-direction": "column",
gap: "12px",
width: "100%",
"padding-top": "12px",
"padding-bottom": "12px",
}}
>
<Tabs.SectionTitle>Desktop</Tabs.SectionTitle>
<div style={{ display: "flex", "flex-direction": "column", gap: "6px", width: "100%" }}>
<Tabs.Trigger value="general">
<Icon name="sliders" />
General
</Tabs.Trigger>
<Tabs.Trigger value="shortcuts">
<Icon name="keyboard" />
Shortcuts
</Tabs.Trigger>
</div>
</div>
{/* <Tabs.SectionTitle>Server</Tabs.SectionTitle> */}
{/* <Tabs.Trigger value="permissions"> */}
{/* <Icon name="checklist" /> */}
{/* Permissions */}
{/* </Tabs.Trigger> */}
{/* <Tabs.Trigger value="providers"> */}
{/* <Icon name="server" /> */}
{/* Providers */}
{/* </Tabs.Trigger> */}
{/* <Tabs.Trigger value="models"> */}
{/* <Icon name="brain" /> */}
{/* Models */}
{/* </Tabs.Trigger> */}
{/* <Tabs.Trigger value="agents"> */}
{/* <Icon name="task" /> */}
{/* Agents */}
{/* </Tabs.Trigger> */}
{/* <Tabs.Trigger value="commands"> */}
{/* <Icon name="console" /> */}
{/* Commands */}
{/* </Tabs.Trigger> */}
{/* <Tabs.Trigger value="mcp"> */}
{/* <Icon name="mcp" /> */}
{/* MCP */}
{/* </Tabs.Trigger> */}
</Tabs.List>
<Tabs.Content value="general" class="no-scrollbar">
<SettingsGeneral />
</Tabs.Content>
<Tabs.Content value="shortcuts" class="no-scrollbar">
<SettingsKeybinds />
</Tabs.Content>
{/* <Tabs.Content value="permissions" class="no-scrollbar"> */}
{/* <SettingsPermissions /> */}
{/* </Tabs.Content> */}
{/* <Tabs.Content value="providers" class="no-scrollbar"> */}
{/* <SettingsProviders /> */}
{/* </Tabs.Content> */}
{/* <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>
)
}
+14 -5
View File
@@ -44,7 +44,7 @@ import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers"
import { formatKeybind, useCommand } from "@/context/command"
import { useCommand } from "@/context/command"
import { Persist, persisted } from "@/utils/persist"
import { Identifier } from "@/utils/id"
import { SessionContextUsage } from "@/components/session-context-usage"
@@ -255,7 +255,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
createEffect(() => {
params.id
editorRef.focus()
if (params.id) return
const interval = setInterval(() => {
setStore("placeholder", (prev) => (prev + 1) % PLACEHOLDERS.length)
@@ -300,7 +299,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
event.stopPropagation()
const items = Array.from(clipboardData.items)
const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
const fileItems = items.filter((item) => item.kind === "file")
const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
if (imageItems.length > 0) {
for (const item of imageItems) {
@@ -310,7 +310,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}
if (fileItems.length > 0) {
showToast({
title: "Unsupported paste",
description: "Only images or PDFs can be pasted here.",
})
return
}
const plainText = clipboardData.getData("text/plain") ?? ""
if (!plainText) return
addPart({ type: "text", content: plainText, start: 0, end: 0 })
}
@@ -1400,8 +1409,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
custom
</span>
</Show>
<Show when={cmd.keybind}>
<span class="text-12-regular text-text-subtle">{formatKeybind(cmd.keybind!)}</span>
<Show when={command.keybind(cmd.id)}>
<span class="text-12-regular text-text-subtle">{command.keybind(cmd.id)}</span>
</Show>
</div>
</button>
@@ -1,31 +0,0 @@
import { onMount } from "solid-js"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogReleaseNotes } from "./dialog-release-notes"
import { shouldShowReleaseNotes, markReleaseNotesSeen } from "@/lib/release-notes"
/**
* Component that handles showing release notes modal on app startup.
* Shows the modal if:
* - DEV_ALWAYS_SHOW_RELEASE_NOTES is true in lib/release-notes.ts
* - OR the user hasn't seen the current version's release notes yet
*
* To disable the dev mode behavior, set DEV_ALWAYS_SHOW_RELEASE_NOTES to false
* in packages/app/src/lib/release-notes.ts
*/
export function ReleaseNotesHandler() {
const dialog = useDialog()
onMount(() => {
// Small delay to ensure app is fully loaded before showing modal
setTimeout(() => {
if (shouldShowReleaseNotes()) {
dialog.show(
() => <DialogReleaseNotes />,
() => markReleaseNotesSeen(),
)
}
}, 500)
})
return null
}
@@ -45,6 +45,8 @@ export function SessionHeader() {
const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id))
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const showReview = createMemo(() => !!currentSession()?.summary?.files)
const showShare = createMemo(() => shareEnabled() && !!currentSession())
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey()))
@@ -172,12 +174,14 @@ export function SessionHeader() {
{/* <SessionMcpIndicator /> */}
{/* </div> */}
<div class="flex items-center gap-1">
<Show when={currentSession()?.summary?.files}>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle review"
keybind={command.keybind("review.toggle")}
>
<div
class="hidden md:block shrink-0"
classList={{
"opacity-0 pointer-events-none": !showReview(),
}}
aria-hidden={!showReview()}
>
<TooltipKeybind title="Toggle review" keybind={command.keybind("review.toggle")}>
<Button
variant="ghost"
class="group/review-toggle size-6 p-0"
@@ -202,7 +206,7 @@ export function SessionHeader() {
</div>
</Button>
</TooltipKeybind>
</Show>
</div>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle terminal"
@@ -233,83 +237,87 @@ export function SessionHeader() {
</Button>
</TooltipKeybind>
</div>
<Show when={shareEnabled() && currentSession()}>
<div class="flex items-center">
<Popover
title="Publish on web"
description={
shareUrl()
? "This session is public on the web. It is accessible to anyone with the link."
: "Share session publicly on the web. It will be accessible to anyone with the link."
}
trigger={
<Tooltip class="shrink-0" value="Share session">
<Button
variant="secondary"
classList={{ "rounded-r-none": shareUrl() !== undefined }}
style={{ scale: 1 }}
>
Share
</Button>
</Tooltip>
}
>
<div class="flex flex-col gap-2">
<Show
when={shareUrl()}
fallback={
<div class="flex">
<Button
size="large"
variant="primary"
class="w-1/2"
onClick={shareSession}
disabled={state.share}
>
{state.share ? "Publishing..." : "Publish"}
</Button>
</div>
}
>
<div class="flex flex-col gap-2 w-72">
<TextField value={shareUrl() ?? ""} readOnly copyable class="w-full" />
<div class="grid grid-cols-2 gap-2">
<Button
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession}
disabled={state.unshare}
>
{state.unshare ? "Unpublishing..." : "Unpublish"}
</Button>
<Button
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={state.unshare}
>
View
</Button>
</div>
</div>
</Show>
</div>
</Popover>
<Show when={shareUrl()} fallback={<div class="size-6" aria-hidden="true" />}>
<Tooltip value={state.copied ? "Copied" : "Copy link"} placement="top" gutter={8}>
<IconButton
icon={state.copied ? "check" : "copy"}
<div
class="flex items-center"
classList={{
"opacity-0 pointer-events-none": !showShare(),
}}
aria-hidden={!showShare()}
>
<Popover
title="Publish on web"
description={
shareUrl()
? "This session is public on the web. It is accessible to anyone with the link."
: "Share session publicly on the web. It will be accessible to anyone with the link."
}
trigger={
<Tooltip class="shrink-0" value="Share session">
<Button
variant="secondary"
class="rounded-l-none"
onClick={copyLink}
disabled={state.unshare}
/>
classList={{ "rounded-r-none": shareUrl() !== undefined }}
style={{ scale: 1 }}
>
Share
</Button>
</Tooltip>
</Show>
</div>
</Show>
}
>
<div class="flex flex-col gap-2">
<Show
when={shareUrl()}
fallback={
<div class="flex">
<Button
size="large"
variant="primary"
class="w-1/2"
onClick={shareSession}
disabled={state.share}
>
{state.share ? "Publishing..." : "Publish"}
</Button>
</div>
}
>
<div class="flex flex-col gap-2 w-72">
<TextField value={shareUrl() ?? ""} readOnly copyable class="w-full" />
<div class="grid grid-cols-2 gap-2">
<Button
size="large"
variant="secondary"
class="w-full shadow-none border border-border-weak-base"
onClick={unshareSession}
disabled={state.unshare}
>
{state.unshare ? "Unpublishing..." : "Unpublish"}
</Button>
<Button
size="large"
variant="primary"
class="w-full"
onClick={viewShare}
disabled={state.unshare}
>
View
</Button>
</div>
</div>
</Show>
</div>
</Popover>
<Show when={shareUrl()} fallback={<div class="size-6" aria-hidden="true" />}>
<Tooltip value={state.copied ? "Copied" : "Copy link"} placement="top" gutter={8}>
<IconButton
icon={state.copied ? "check" : "copy"}
variant="secondary"
class="rounded-l-none"
onClick={copyLink}
disabled={state.unshare}
/>
</Tooltip>
</Show>
</div>
</div>
</Portal>
)}
@@ -0,0 +1,12 @@
import { Component } from "solid-js"
export const SettingsAgents: Component = () => {
return (
<div class="flex flex-col h-full overflow-y-auto">
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
<h2 class="text-16-medium text-text-strong">Agents</h2>
<p class="text-14-regular text-text-weak">Agent settings will be configurable here.</p>
</div>
</div>
)
}
@@ -0,0 +1,12 @@
import { Component } from "solid-js"
export const SettingsCommands: Component = () => {
return (
<div class="flex flex-col h-full overflow-y-auto">
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
<h2 class="text-16-medium text-text-strong">Commands</h2>
<p class="text-14-regular text-text-weak">Command settings will be configurable here.</p>
</div>
</div>
)
}
@@ -0,0 +1,246 @@
import { Component, createMemo, type JSX } from "solid-js"
import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { useSettings, monoFontFamily } from "@/context/settings"
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
export const SettingsGeneral: Component = () => {
const theme = useTheme()
const settings = useSettings()
const themeOptions = createMemo(() =>
Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })),
)
const colorSchemeOptions: { value: ColorScheme; label: string }[] = [
{ value: "system", label: "System setting" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
]
const fontOptions = [
{ value: "ibm-plex-mono", label: "IBM Plex Mono" },
{ value: "cascadia-code", label: "Cascadia Code" },
{ value: "fira-code", label: "Fira Code" },
{ value: "hack", label: "Hack" },
{ value: "inconsolata", label: "Inconsolata" },
{ value: "intel-one-mono", label: "Intel One Mono" },
{ value: "jetbrains-mono", label: "JetBrains Mono" },
{ value: "meslo-lgs", label: "Meslo LGS" },
{ value: "roboto-mono", label: "Roboto Mono" },
{ value: "source-code-pro", label: "Source Code Pro" },
{ value: "ubuntu-mono", label: "Ubuntu Mono" },
]
const soundOptions = [...SOUND_OPTIONS]
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar" style={{ padding: "0 40px 40px 40px" }}>
<div
class="sticky top-0 z-10"
style={{
background:
"linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha) calc(100% - 24px), transparent)",
}}
>
<div class="flex flex-col gap-1 pt-6 pb-8">
<h2 class="text-16-medium text-text-strong">General</h2>
</div>
</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">Appearance</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow title="Appearance" description="Customise how OpenCode looks on your device">
<Select
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="Theme"
description={
<>
Customise how OpenCode is themed.{" "}
<a href="#" class="text-text-interactive-base">
Learn more
</a>
</>
}
>
<Select
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="Font" description="Customise the mono font used in code blocks">
<Select
options={fontOptions}
current={fontOptions.find((o) => o.value === settings.appearance.font())}
value={(o) => o.value}
label={(o) => 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?.label}</span>}
</Select>
</SettingsRow>
</div>
</div>
{/* System notifications Section */}
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">System notifications</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title="Agent"
description="Show system notification when the agent is complete or needs attention"
>
<Switch
checked={settings.notifications.agent()}
onChange={(checked) => settings.notifications.setAgent(checked)}
/>
</SettingsRow>
<SettingsRow title="Permissions" description="Show system notification when a permission is required">
<Switch
checked={settings.notifications.permissions()}
onChange={(checked) => settings.notifications.setPermissions(checked)}
/>
</SettingsRow>
<SettingsRow title="Errors" description="Show system notification when an error occurs">
<Switch
checked={settings.notifications.errors()}
onChange={(checked) => settings.notifications.setErrors(checked)}
/>
</SettingsRow>
</div>
</div>
{/* Sound effects Section */}
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">Sound effects</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow title="Agent" description="Play sound when the agent is complete or needs attention">
<Select
options={soundOptions}
current={soundOptions.find((o) => o.id === settings.sounds.agent())}
value={(o) => o.id}
label={(o) => o.label}
onHighlight={(option) => {
if (!option) return
playSound(option.src)
}}
onSelect={(option) => {
if (!option) return
settings.sounds.setAgent(option.id)
playSound(option.src)
}}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<SettingsRow title="Permissions" description="Play sound when a permission is required">
<Select
options={soundOptions}
current={soundOptions.find((o) => o.id === settings.sounds.permissions())}
value={(o) => o.id}
label={(o) => o.label}
onHighlight={(option) => {
if (!option) return
playSound(option.src)
}}
onSelect={(option) => {
if (!option) return
settings.sounds.setPermissions(option.id)
playSound(option.src)
}}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
<SettingsRow title="Errors" description="Play sound when an error occurs">
<Select
options={soundOptions}
current={soundOptions.find((o) => o.id === settings.sounds.errors())}
value={(o) => o.id}
label={(o) => o.label}
onHighlight={(option) => {
if (!option) return
playSound(option.src)
}}
onSelect={(option) => {
if (!option) return
settings.sounds.setErrors(option.id)
playSound(option.src)
}}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
</div>
</div>
</div>
</div>
)
}
interface SettingsRowProps {
title: string
description: string | JSX.Element
children: JSX.Element
}
const SettingsRow: Component<SettingsRowProps> = (props) => {
return (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<div class="flex flex-col gap-0.5">
<span class="text-14-medium text-text-strong">{props.title}</span>
<span class="text-12-regular text-text-weak">{props.description}</span>
</div>
<div class="flex-shrink-0">{props.children}</div>
</div>
)
}
@@ -0,0 +1,334 @@
import { Component, For, Show, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@opencode-ai/ui/toast"
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
import { useSettings } from "@/context/settings"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette"
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
type KeybindGroup = "General" | "Session" | "Navigation" | "Model and agent" | "Terminal" | "Prompt"
type KeybindMeta = {
title: string
group: KeybindGroup
}
const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"]
function groupFor(id: string): KeybindGroup {
if (id === PALETTE_ID) return "General"
if (id.startsWith("terminal.")) return "Terminal"
if (id.startsWith("model.") || id.startsWith("agent.") || id.startsWith("mcp.")) return "Model and agent"
if (id.startsWith("file.")) return "Navigation"
if (id.startsWith("prompt.")) return "Prompt"
if (
id.startsWith("session.") ||
id.startsWith("message.") ||
id.startsWith("permissions.") ||
id.startsWith("steps.") ||
id.startsWith("review.")
)
return "Session"
return "General"
}
function isModifier(key: string) {
return key === "Shift" || key === "Control" || key === "Alt" || key === "Meta"
}
function normalizeKey(key: string) {
if (key === ",") return "comma"
if (key === "+") return "plus"
if (key === " ") return "space"
return key.toLowerCase()
}
function recordKeybind(event: KeyboardEvent) {
if (isModifier(event.key)) return
const parts: string[] = []
const mod = IS_MAC ? event.metaKey : event.ctrlKey
if (mod) parts.push("mod")
if (IS_MAC && event.ctrlKey) parts.push("ctrl")
if (!IS_MAC && event.metaKey) parts.push("meta")
if (event.altKey) parts.push("alt")
if (event.shiftKey) parts.push("shift")
const key = normalizeKey(event.key)
if (!key) return
parts.push(key)
return parts.join("+")
}
function signatures(config: string | undefined) {
if (!config) return []
const sigs: string[] = []
for (const kb of parseKeybind(config)) {
const parts: string[] = []
if (kb.ctrl) parts.push("ctrl")
if (kb.alt) parts.push("alt")
if (kb.shift) parts.push("shift")
if (kb.meta) parts.push("meta")
if (kb.key) parts.push(kb.key)
if (parts.length === 0) continue
sigs.push(parts.join("+"))
}
return sigs
}
export const SettingsKeybinds: Component = () => {
const command = useCommand()
const settings = useSettings()
const [active, setActive] = createSignal<string | null>(null)
const stop = () => {
if (!active()) return
setActive(null)
command.keybinds(true)
}
const start = (id: string) => {
if (active() === id) {
stop()
return
}
if (active()) stop()
setActive(id)
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 resetAll = () => {
stop()
settings.keybinds.resetAll()
showToast({ title: "Shortcuts reset", description: "Keyboard shortcuts have been reset to defaults." })
}
const list = createMemo(() => {
const out = new Map<string, KeybindMeta>()
out.set(PALETTE_ID, { title: "Command palette", group: "General" })
for (const opt of command.catalog) {
if (opt.id.startsWith("suggested.")) continue
out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
}
for (const opt of command.options) {
if (opt.id.startsWith("suggested.")) continue
out.set(opt.id, { title: opt.title, group: groupFor(opt.id) })
}
const keybinds = settings.current.keybinds as Record<string, string | undefined> | undefined
if (keybinds) {
for (const [id, value] of Object.entries(keybinds)) {
if (typeof value !== "string") continue
if (out.has(id)) continue
out.set(id, { title: id, group: groupFor(id) })
}
}
return out
})
const title = (id: string) => list().get(id)?.title ?? ""
const 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 used = createMemo(() => {
const map = new Map<string, { id: string; title: string }[]>()
const add = (key: string, value: { id: string; title: string }) => {
const list = map.get(key)
if (!list) {
map.set(key, [value])
return
}
list.push(value)
}
const palette = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
for (const sig of signatures(palette)) {
add(sig, { id: PALETTE_ID, title: "Command palette" })
}
const valueFor = (id: string) => {
const custom = settings.keybinds.get(id)
if (typeof custom === "string") return custom
const live = command.options.find((x) => x.id === id)
if (live?.keybind) return live.keybind
const meta = command.catalog.find((x) => x.id === id)
return meta?.keybind
}
for (const id of list().keys()) {
if (id === PALETTE_ID) continue
for (const sig of signatures(valueFor(id))) {
add(sig, { id, title: title(id) })
}
}
return map
})
const setKeybind = (id: string, keybind: string) => {
settings.keybinds.set(id, keybind)
}
onMount(() => {
const handle = (event: KeyboardEvent) => {
const id = 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: "Shortcut already in use",
description: `${formatKeybind(next)} is already assigned to ${[...conflicts.values()].join(", ")}.`,
})
return
}
setKeybind(id, next)
stop()
}
document.addEventListener("keydown", handle, true)
onCleanup(() => {
document.removeEventListener("keydown", handle, true)
})
})
onCleanup(() => {
if (active()) command.keybinds(true)
})
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar" style={{ padding: "0 40px 40px 40px" }}>
<div
class="sticky top-0 z-10"
style={{
background:
"linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha) calc(100% - 24px), transparent)",
}}
>
<div class="flex items-center justify-between gap-4 pt-6 pb-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">Keyboard shortcuts</h2>
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
Reset to defaults
</Button>
</div>
</div>
<div class="flex flex-col gap-8 max-w-[720px]">
<For each={GROUPS}>
{(group) => (
<Show when={(grouped().get(group) ?? []).length > 0}>
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{group}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<For each={grouped().get(group) ?? []}>
{(id) => (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<span class="text-14-regular text-text-strong">{title(id)}</span>
<button
type="button"
classList={{
"h-8 px-3 rounded-md text-12-regular": true,
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
active() !== id,
"border border-border-weak-base bg-surface-inset-base text-text-weak": active() === id,
}}
onClick={() => start(id)}
>
<Show when={active() === id} fallback={command.keybind(id) || "Unassigned"}>
Press keys
</Show>
</button>
</div>
)}
</For>
</div>
</div>
</Show>
)}
</For>
</div>
</div>
)
}
@@ -0,0 +1,12 @@
import { Component } from "solid-js"
export const SettingsMcp: Component = () => {
return (
<div class="flex flex-col h-full overflow-y-auto">
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
<h2 class="text-16-medium text-text-strong">MCP</h2>
<p class="text-14-regular text-text-weak">MCP settings will be configurable here.</p>
</div>
</div>
)
}
@@ -0,0 +1,12 @@
import { Component } from "solid-js"
export const SettingsModels: Component = () => {
return (
<div class="flex flex-col h-full overflow-y-auto">
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
<h2 class="text-16-medium text-text-strong">Models</h2>
<p class="text-14-regular text-text-weak">Model settings will be configurable here.</p>
</div>
</div>
)
}
@@ -0,0 +1,160 @@
import { Select } from "@opencode-ai/ui/select"
import { showToast } from "@opencode-ai/ui/toast"
import { Component, For, createMemo, type JSX } from "solid-js"
import { useGlobalSync } from "@/context/global-sync"
type PermissionAction = "allow" | "ask" | "deny"
type PermissionObject = Record<string, PermissionAction>
type PermissionValue = PermissionAction | PermissionObject | string[] | undefined
type PermissionMap = Record<string, PermissionValue>
type PermissionItem = {
id: string
title: string
description: string
}
const ACTIONS: Array<{ value: PermissionAction; label: string }> = [
{ value: "allow", label: "Allow" },
{ value: "ask", label: "Ask" },
{ value: "deny", label: "Deny" },
]
const ITEMS: PermissionItem[] = [
{ id: "read", title: "Read", description: "Reading a file (matches the file path)" },
{ id: "edit", title: "Edit", description: "Modify files, including edits, writes, patches, and multi-edits" },
{ id: "glob", title: "Glob", description: "Match files using glob patterns" },
{ id: "grep", title: "Grep", description: "Search file contents using regular expressions" },
{ id: "list", title: "List", description: "List files within a directory" },
{ id: "bash", title: "Bash", description: "Run shell commands" },
{ id: "task", title: "Task", description: "Launch sub-agents" },
{ id: "skill", title: "Skill", description: "Load a skill by name" },
{ id: "lsp", title: "LSP", description: "Run language server queries" },
{ id: "todoread", title: "Todo Read", description: "Read the todo list" },
{ id: "todowrite", title: "Todo Write", description: "Update the todo list" },
{ id: "webfetch", title: "Web Fetch", description: "Fetch content from a URL" },
{ id: "websearch", title: "Web Search", description: "Search the web" },
{ id: "codesearch", title: "Code Search", description: "Search code on the web" },
{ id: "external_directory", title: "External Directory", description: "Access files outside the project directory" },
{ id: "doom_loop", title: "Doom Loop", description: "Detect repeated tool calls with identical input" },
]
const VALID_ACTIONS = new Set<PermissionAction>(["allow", "ask", "deny"])
function toMap(value: unknown): PermissionMap {
if (value && typeof value === "object" && !Array.isArray(value)) return value as PermissionMap
const action = getAction(value)
if (action) return { "*": action }
return {}
}
function getAction(value: unknown): PermissionAction | undefined {
if (typeof value === "string" && VALID_ACTIONS.has(value as PermissionAction)) return value as PermissionAction
return
}
function getRuleDefault(value: unknown): PermissionAction | undefined {
const action = getAction(value)
if (action) return action
if (!value || typeof value !== "object" || Array.isArray(value)) return
return getAction((value as Record<string, unknown>)["*"])
}
export const SettingsPermissions: Component = () => {
const globalSync = useGlobalSync()
const permission = createMemo(() => {
return toMap(globalSync.data.config.permission)
})
const actionFor = (id: string): PermissionAction => {
const value = permission()[id]
const direct = getRuleDefault(value)
if (direct) return direct
const wildcard = getRuleDefault(permission()["*"])
if (wildcard) return wildcard
return "allow"
}
const setPermission = async (id: string, action: PermissionAction) => {
const before = globalSync.data.config.permission
const map = toMap(before)
const existing = map[id]
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) => {
globalSync.set("config", "permission", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: "Failed to update permissions", description: message })
})
}
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar">
<div
class="sticky top-0 z-10"
style={{
background:
"linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha) calc(100% - 24px), transparent)",
}}
>
<div class="flex flex-col gap-1 p-8 max-w-[720px]">
<h2 class="text-16-medium text-text-strong">Permissions</h2>
<p class="text-14-regular text-text-weak">Control what tools the server can use by default.</p>
</div>
</div>
<div class="flex flex-col gap-6 p-8 pt-6 max-w-[720px]">
<div class="flex flex-col gap-2">
<h3 class="text-14-medium text-text-strong">Appearance</h3>
<div class="border border-border-weak-base rounded-lg overflow-hidden">
<For each={ITEMS}>
{(item) => (
<SettingsRow title={item.title} description={item.description}>
<Select
options={ACTIONS}
current={ACTIONS.find((o) => o.value === actionFor(item.id))}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && setPermission(item.id, option.value)}
variant="secondary"
size="small"
triggerVariant="settings"
/>
</SettingsRow>
)}
</For>
</div>
</div>
</div>
</div>
)
}
interface SettingsRowProps {
title: string
description: string
children: JSX.Element
}
const SettingsRow: Component<SettingsRowProps> = (props) => {
return (
<div class="flex items-center justify-between gap-4 px-4 py-3 border-b border-border-weak-base last:border-none">
<div class="flex flex-col gap-0.5">
<span class="text-14-medium text-text-strong">{props.title}</span>
<span class="text-12-regular text-text-weak">{props.description}</span>
</div>
<div class="flex-shrink-0">{props.children}</div>
</div>
)
}
@@ -0,0 +1,12 @@
import { Component } from "solid-js"
export const SettingsProviders: Component = () => {
return (
<div class="flex flex-col h-full overflow-y-auto">
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
<h2 class="text-16-medium text-text-strong">Providers</h2>
<p class="text-14-regular text-text-weak">Provider settings will be configurable here.</p>
</div>
</div>
)
}
@@ -1,232 +0,0 @@
import { For, createSignal, Show, onMount, onCleanup } from "solid-js"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { parseKeybind, formatKeybind } from "@/context/command"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const SPECIAL_CHAR_NAMES: Record<string, string> = {
"^": "Control",
"⌥": "Option",
"⇧": "Shift",
"⌘": "Command",
"↑": "Arrow Up",
"↓": "Arrow Down",
"`": "Backtick",
"'": "Quote",
".": "Period",
",": "Comma",
"/": "Slash",
"\\": "Backslash",
"[": "Left Bracket",
"]": "Right Bracket",
"-": "Minus",
"=": "Equals",
";": "Semicolon",
}
const KEY_DISPLAY_MAP: Record<string, string> = {
arrowup: "↑",
arrowdown: "↓",
arrowleft: "←",
arrowright: "→",
backspace: "⌫",
}
interface Shortcut {
title: string
keybind: string
}
interface ShortcutCategory {
name: string
shortcuts: Shortcut[]
}
function isLetter(char: string): boolean {
return /^[A-Za-z]$/.test(char)
}
function getKeyChars(config: string): string[] {
const keybinds = parseKeybind(config)
if (keybinds.length === 0) return []
const kb = keybinds[0]
const chars: string[] = []
if (kb.ctrl) chars.push(IS_MAC ? "^" : "Ctrl")
if (kb.alt) chars.push(IS_MAC ? "⌥" : "Alt")
if (kb.shift) chars.push(IS_MAC ? "⇧" : "Shift")
if (kb.meta) chars.push(IS_MAC ? "⌘" : "Meta")
if (kb.key) {
const mapped = KEY_DISPLAY_MAP[kb.key.toLowerCase()]
if (mapped) {
chars.push(mapped)
} else {
const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1)
for (const char of displayKey) {
chars.push(char)
}
}
}
return chars
}
const SHORTCUT_CATEGORIES: ShortcutCategory[] = [
{
name: "General",
shortcuts: [
{ title: "Command palette", keybind: "mod+shift+p" },
{ title: "Toggle sidebar", keybind: "mod+b" },
{ title: "Toggle shortcuts", keybind: "ctrl+/" },
{ title: "Open file", keybind: "mod+p" },
{ title: "Open project", keybind: "mod+o" },
],
},
{
name: "Session",
shortcuts: [
{ title: "New session", keybind: "mod+shift+s" },
{ title: "Previous session", keybind: "alt+arrowup" },
{ title: "Next session", keybind: "alt+arrowdown" },
{ title: "Archive session", keybind: "mod+shift+backspace" },
{ title: "Undo", keybind: "mod+z" },
{ title: "Redo", keybind: "mod+shift+z" },
],
},
{
name: "Navigation",
shortcuts: [
{ title: "Previous message", keybind: "mod+arrowup" },
{ title: "Next message", keybind: "mod+arrowdown" },
{ title: "Toggle steps", keybind: "mod+e" },
],
},
{
name: "Model and Agent",
shortcuts: [
{ title: "Choose model", keybind: "mod+'" },
{ title: "Cycle agent", keybind: "mod+." },
],
},
{
name: "Terminal",
shortcuts: [
{ title: "Toggle terminal", keybind: "ctrl+`" },
{ title: "New terminal", keybind: "ctrl+shift+`" },
],
},
]
const USED_SHORTCUTS_KEY = "opencode:used-shortcuts"
function getUsedShortcuts(): Set<string> {
const stored = localStorage.getItem(USED_SHORTCUTS_KEY)
return stored ? new Set(JSON.parse(stored)) : new Set()
}
const [usedShortcuts, setUsedShortcuts] = createSignal(getUsedShortcuts())
function formatKeybindForCopy(config: string): string {
const chars = getKeyChars(config)
return chars.join("")
}
function ShortcutItem(props: { shortcut: Shortcut }) {
const [copied, setCopied] = createSignal(false)
const used = () => usedShortcuts().has(props.shortcut.keybind)
function copyToClipboard() {
const text = formatKeybindForCopy(props.shortcut.keybind)
navigator.clipboard.writeText(text)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
return (
<Tooltip value="Copy to clipboard" placement="top">
<button type="button" class="shortcut-item" classList={{ "shortcut-used": used() }} onClick={copyToClipboard}>
<span class="text-14-regular text-text-base">{props.shortcut.title}</span>
<Show
when={!copied()}
fallback={
<div class="shortcut-copied">
<Icon name="check" size="small" />
</div>
}
>
<div class="shortcut-keys">
<For each={getKeyChars(props.shortcut.keybind)}>
{(char) => {
const tooltip = SPECIAL_CHAR_NAMES[char]
const isSpecial = tooltip && !isLetter(char)
const isShift = char === "⇧"
return (
<Show when={isSpecial} fallback={<kbd class="shortcut-key">{char}</kbd>}>
<Tooltip value={tooltip} placement="top">
<kbd class="shortcut-key shortcut-key-special">
<span classList={{ "shortcut-key-shift": isShift }}>{char}</span>
</kbd>
</Tooltip>
</Show>
)
}}
</For>
</div>
</Show>
</button>
</Tooltip>
)
}
export function ShortcutsPanel(props: { onClose: () => void }) {
const [activeTab, setActiveTab] = createSignal(SHORTCUT_CATEGORIES[0].name)
let listRef: HTMLDivElement | undefined
onMount(() => {
const handler = () => setUsedShortcuts(getUsedShortcuts())
window.addEventListener("shortcut-used", handler)
onCleanup(() => window.removeEventListener("shortcut-used", handler))
// Focus the active tab trigger so arrow keys work immediately
const trigger = listRef?.querySelector<HTMLButtonElement>('[data-slot="tabs-trigger"][data-selected]')
trigger?.focus()
})
return (
<div class="shortcuts-panel" data-component="shortcuts-panel">
<Tabs value={activeTab()} onChange={setActiveTab}>
<div class="shortcuts-tabs-row">
<Tabs.List ref={listRef} class="shortcuts-tabs-list">
<For each={SHORTCUT_CATEGORIES}>
{(category) => <Tabs.Trigger value={category.name}>{category.name}</Tabs.Trigger>}
</For>
</Tabs.List>
<Tooltip
placement="top"
value={
<span>
Close <span class="text-text-weak">{formatKeybind("ctrl+/")}</span>
</span>
}
>
<IconButton icon="close" variant="ghost" onClick={props.onClose} />
</Tooltip>
</div>
<For each={SHORTCUT_CATEGORIES}>
{(category) => (
<Tabs.Content value={category.name} class="shortcuts-content">
<div class="shortcuts-grid">
<For each={category.shortcuts}>{(shortcut) => <ShortcutItem shortcut={shortcut} />}</For>
</div>
</Tabs.Content>
)}
</For>
</Tabs>
</div>
)
}
+11 -1
View File
@@ -1,6 +1,7 @@
import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
import { useSDK } from "@/context/sdk"
import { monoFontFamily, useSettings } from "@/context/settings"
import { SerializeAddon } from "@/addons/serialize"
import { LocalPTY } from "@/context/terminal"
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
@@ -36,6 +37,7 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
export const Terminal = (props: TerminalProps) => {
const sdk = useSDK()
const settings = useSettings()
const theme = useTheme()
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
@@ -82,6 +84,14 @@ export const Terminal = (props: TerminalProps) => {
setOption("theme", colors)
})
createEffect(() => {
const font = monoFontFamily(settings.appearance.font())
if (!term) return
const setOption = (term as unknown as { setOption?: (key: string, value: string) => void }).setOption
if (!setOption) return
setOption("fontFamily", font)
})
const focusTerminal = () => {
const t = term
if (!t) return
@@ -112,7 +122,7 @@ export const Terminal = (props: TerminalProps) => {
cursorBlink: true,
cursorStyle: "bar",
fontSize: 14,
fontFamily: "IBM Plex Mono, monospace",
fontFamily: monoFontFamily(settings.appearance.font()),
allowTransparency: true,
theme: terminalColors(),
scrollback: 10_000,
+95 -87
View File
@@ -1,11 +1,28 @@
import { createMemo, createSignal, onCleanup, onMount, Show, type Accessor } from "solid-js"
import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { useSettings } from "@/context/settings"
import { Persist, persisted } from "@/utils/persist"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette"
const DEFAULT_PALETTE_KEYBIND = "mod+shift+p"
const SUGGESTED_PREFIX = "suggested."
function actionId(id: string) {
if (!id.startsWith(SUGGESTED_PREFIX)) return id
return id.slice(SUGGESTED_PREFIX.length)
}
function normalizeKey(key: string) {
if (key === ",") return "comma"
if (key === "+") return "plus"
if (key === " ") return "space"
return key.toLowerCase()
}
export type KeybindConfig = string
export interface Keybind {
@@ -29,6 +46,14 @@ export interface CommandOption {
onHighlight?: () => (() => void) | void
}
export type CommandCatalogItem = {
title: string
description?: string
category?: string
keybind?: KeybindConfig
slash?: string
}
export function parseKeybind(config: string): Keybind[] {
if (!config || config === "none") return []
@@ -75,7 +100,7 @@ export function parseKeybind(config: string): Keybind[] {
}
export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
const eventKey = event.key.toLowerCase()
const eventKey = normalizeKey(event.key)
for (const kb of keybinds) {
const keyMatch = kb.key === eventKey
@@ -107,96 +132,44 @@ export function formatKeybind(config: string): string {
if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
if (kb.key) {
const arrows: Record<string, string> = {
const keys: Record<string, string> = {
arrowup: "↑",
arrowdown: "↓",
arrowleft: "←",
arrowright: "→",
comma: ",",
plus: "+",
space: "Space",
}
const displayKey =
arrows[kb.key.toLowerCase()] ??
(kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1))
const key = kb.key.toLowerCase()
const displayKey = keys[key] ?? (key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1))
parts.push(displayKey)
}
return IS_MAC ? parts.join("") : parts.join("+")
}
function DialogCommand(props: { options: CommandOption[] }) {
const dialog = useDialog()
const state = { cleanup: undefined as (() => void) | void, committed: false }
const handleMove = (option: CommandOption | undefined) => {
state.cleanup?.()
if (!option) return
state.cleanup = option.onHighlight?.()
}
const handleSelect = (option: CommandOption | undefined) => {
if (!option) return
state.committed = true
state.cleanup = undefined
dialog.close()
option.onSelect?.("palette")
}
onCleanup(() => {
if (state.committed) return
state.cleanup?.()
})
return (
<Dialog title="Commands">
<List
class="px-2.5"
search={{ placeholder: "Search commands", autofocus: true }}
emptyMessage="No commands found"
items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
key={(x) => x?.id}
filterKeys={["title", "description", "category"]}
groupBy={(x) => x.category ?? ""}
onMove={handleMove}
onSelect={handleSelect}
>
{(option) => (
<div class="w-full flex items-center justify-between gap-4">
<div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong whitespace-nowrap">{option.title}</span>
<Show when={option.description}>
<span class="text-14-regular text-text-weak truncate">{option.description}</span>
</Show>
</div>
<Show when={option.keybind}>
<span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(option.keybind!)}</span>
</Show>
</div>
)}
</List>
</Dialog>
)
}
const USED_SHORTCUTS_KEY = "opencode:used-shortcuts"
function getUsedShortcuts(): Set<string> {
const stored = localStorage.getItem(USED_SHORTCUTS_KEY)
return stored ? new Set(JSON.parse(stored)) : new Set()
}
function markShortcutUsed(keybind: string) {
const used = getUsedShortcuts()
used.add(keybind)
localStorage.setItem(USED_SHORTCUTS_KEY, JSON.stringify([...used]))
window.dispatchEvent(new CustomEvent("shortcut-used", { detail: keybind }))
}
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
name: "Command",
init: () => {
const dialog = useDialog()
const settings = useSettings()
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
const [suspendCount, setSuspendCount] = createSignal(0)
const options = createMemo(() => {
const [catalog, setCatalog, _, catalogReady] = persisted(
Persist.global("command.catalog.v1"),
createStore<Record<string, CommandCatalogItem>>({}),
)
const bind = (id: string, def: KeybindConfig | undefined) => {
const custom = settings.keybinds.get(actionId(id))
const config = custom ?? def
if (!config || config === "none") return
return config
}
const registered = createMemo(() => {
const seen = new Set<string>()
const all: CommandOption[] = []
@@ -208,15 +181,41 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}
}
const suggested = all.filter((x) => x.suggested && !x.disabled)
return all
})
createEffect(() => {
if (!catalogReady()) return
for (const opt of registered()) {
const id = actionId(opt.id)
setCatalog(id, {
title: opt.title,
description: opt.description,
category: opt.category,
keybind: opt.keybind,
slash: opt.slash,
})
}
})
const catalogOptions = createMemo(() => Object.entries(catalog).map(([id, meta]) => ({ id, ...meta })))
const options = createMemo(() => {
const resolved = registered().map((opt) => ({
...opt,
keybind: bind(opt.id, opt.keybind),
}))
const suggested = resolved.filter((x) => x.suggested && !x.disabled)
return [
...suggested.map((x) => ({
...x,
id: "suggested." + x.id,
id: SUGGESTED_PREFIX + x.id,
category: "Suggested",
})),
...all,
...resolved,
]
})
@@ -232,17 +231,15 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}
const showPalette = () => {
if (dialog.active) return
dialog.show(() => <DialogCommand options={options().filter((x) => !x.disabled)} />)
run("file.open", "palette")
}
const handleKeyDown = (event: KeyboardEvent) => {
if (suspended() || dialog.active) return
const paletteKeybinds = parseKeybind("mod+shift+p")
const paletteKeybinds = parseKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND)
if (matchKeybind(paletteKeybinds, event)) {
event.preventDefault()
markShortcutUsed("mod+shift+p")
showPalette()
return
}
@@ -254,7 +251,6 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const keybinds = parseKeybind(option.keybind)
if (matchKeybind(keybinds, event)) {
event.preventDefault()
markShortcutUsed(option.keybind)
option.onSelect?.("keybind")
return
}
@@ -281,15 +277,27 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
run(id, source)
},
keybind(id: string) {
const option = options().find((x) => x.id === id || x.id === "suggested." + id)
if (!option?.keybind) return ""
return formatKeybind(option.keybind)
if (id === PALETTE_ID) {
return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND)
}
const base = actionId(id)
const option = options().find((x) => actionId(x.id) === base)
if (option?.keybind) return formatKeybind(option.keybind)
const meta = catalog[base]
const config = bind(base, meta?.keybind)
if (!config) return ""
return formatKeybind(config)
},
show: showPalette,
keybinds(enabled: boolean) {
setSuspendCount((count) => count + (enabled ? -1 : 1))
},
suspended,
get catalog() {
return catalogOptions()
},
get options() {
return options()
},
+211 -119
View File
@@ -88,6 +88,10 @@ type VcsCache = {
ready: Accessor<boolean>
}
type ChildOptions = {
bootstrap?: boolean
}
function createGlobalSync() {
const globalSDK = useGlobalSDK()
const platform = usePlatform()
@@ -101,17 +105,37 @@ function createGlobalSync() {
project: Project[]
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
config: Config
reload: undefined | "pending" | "complete"
}>({
ready: false,
path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: [],
provider: { all: [], connected: [], default: {} },
provider_auth: {},
config: {},
reload: undefined,
})
let bootstrapQueue: string[] = []
createEffect(async () => {
if (globalStore.reload !== "complete") return
if (bootstrapQueue.length) {
for (const directory of bootstrapQueue) {
bootstrapInstance(directory)
}
bootstrap()
}
bootstrapQueue = []
setGlobalStore("reload", undefined)
})
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
const booting = new Map<string, Promise<void>>()
const sessionLoads = new Map<string, Promise<void>>()
const sessionMeta = new Map<string, { limit: number }>()
function child(directory: string) {
function ensureChild(directory: string) {
if (!directory) console.error("No directory provided")
if (!children[directory]) {
const cache = runWithOwner(owner, () =>
@@ -146,7 +170,6 @@ function createGlobalSync() {
message: {},
part: {},
})
bootstrapInstance(directory)
}
runWithOwner(owner, init)
@@ -156,11 +179,24 @@ function createGlobalSync() {
return childStore
}
async function loadSessions(directory: string) {
const [store, setStore] = child(directory)
const limit = store.limit
function child(directory: string, options: ChildOptions = {}) {
const childStore = ensureChild(directory)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") {
void bootstrapInstance(directory)
}
return childStore
}
return globalSDK.client.session
async function loadSessions(directory: string) {
const pending = sessionLoads.get(directory)
if (pending) return pending
const [store, setStore] = child(directory, { bootstrap: false })
const meta = sessionMeta.get(directory)
if (meta && meta.limit >= store.limit) return
const promise = globalSDK.client.session
.list({ directory, roots: true })
.then((x) => {
const nonArchived = (x.data ?? [])
@@ -169,9 +205,15 @@ function createGlobalSync() {
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
// Read the current limit at resolve-time so callers that bump the limit while
// a request is in-flight still get the expanded result.
const limit = store.limit
const sandboxWorkspace = globalStore.project.some((p) => (p.sandboxes ?? []).includes(directory))
if (sandboxWorkspace) {
setStore("sessionTotal", nonArchived.length)
setStore("session", reconcile(nonArchived, { key: "id" }))
sessionMeta.set(directory, { limit })
return
}
@@ -185,136 +227,164 @@ function createGlobalSync() {
// Store total session count (used for "load more" pagination)
setStore("sessionTotal", nonArchived.length)
setStore("session", reconcile(sessions, { key: "id" }))
sessionMeta.set(directory, { limit })
})
.catch((err) => {
console.error("Failed to load sessions", err)
const project = getFilename(directory)
showToast({ title: `Failed to load sessions for ${project}`, description: err.message })
})
sessionLoads.set(directory, promise)
promise.finally(() => {
sessionLoads.delete(directory)
})
return promise
}
async function bootstrapInstance(directory: string) {
if (!directory) return
const [store, setStore] = child(directory)
const cache = vcsCache.get(directory)
if (!cache) return
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory,
throwOnError: true,
})
const pending = booting.get(directory)
if (pending) return pending
createEffect(() => {
if (!cache.ready()) return
const cached = cache.store.value
if (!cached?.branch) return
setStore("vcs", (value) => value ?? cached)
})
const promise = (async () => {
const [store, setStore] = ensureChild(directory)
const cache = vcsCache.get(directory)
if (!cache) return
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory,
throwOnError: true,
})
const blockingRequests = {
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
provider: () =>
sdk.provider.list().then((x) => {
const data = x.data!
setStore("provider", {
...data,
all: data.all.map((provider) => ({
...provider,
models: Object.fromEntries(
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
),
})),
setStore("status", "loading")
createEffect(() => {
if (!cache.ready()) return
const cached = cache.store.value
if (!cached?.branch) return
setStore("vcs", (value) => value ?? cached)
})
const blockingRequests = {
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
provider: () =>
sdk.provider.list().then((x) => {
const data = x.data!
setStore("provider", {
...data,
all: data.all.map((provider) => ({
...provider,
models: Object.fromEntries(
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
),
})),
})
}),
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
}
try {
await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
} catch (err) {
console.error("Failed to bootstrap instance", err)
const project = getFilename(directory)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: `Failed to reload ${project}`, description: message })
setStore("status", "partial")
return
}
if (store.status !== "complete") setStore("status", "partial")
Promise.all([
sdk.path.get().then((x) => setStore("path", x.data!)),
sdk.command.list().then((x) => setStore("command", x.data ?? [])),
sdk.session.status().then((x) => setStore("session_status", x.data!)),
loadSessions(directory),
sdk.mcp.status().then((x) => setStore("mcp", x.data!)),
sdk.lsp.status().then((x) => setStore("lsp", x.data!)),
sdk.vcs.get().then((x) => {
const next = x.data ?? store.vcs
setStore("vcs", next)
if (next?.branch) cache.setStore("value", next)
}),
sdk.permission.list().then((x) => {
const grouped: Record<string, PermissionRequest[]> = {}
for (const perm of x.data ?? []) {
if (!perm?.id || !perm.sessionID) continue
const existing = grouped[perm.sessionID]
if (existing) {
existing.push(perm)
continue
}
grouped[perm.sessionID] = [perm]
}
batch(() => {
for (const sessionID of Object.keys(store.permission)) {
if (grouped[sessionID]) continue
setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
setStore(
"permission",
sessionID,
reconcile(
permissions
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
})
}),
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
}
await Promise.all(Object.values(blockingRequests).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
.then(() => {
if (store.status !== "complete") setStore("status", "partial")
// non-blocking
Promise.all([
sdk.path.get().then((x) => setStore("path", x.data!)),
sdk.command.list().then((x) => setStore("command", x.data ?? [])),
sdk.session.status().then((x) => setStore("session_status", x.data!)),
loadSessions(directory),
sdk.mcp.status().then((x) => setStore("mcp", x.data!)),
sdk.lsp.status().then((x) => setStore("lsp", x.data!)),
sdk.vcs.get().then((x) => {
const next = x.data ?? store.vcs
setStore("vcs", next)
if (next?.branch) cache.setStore("value", next)
}),
sdk.permission.list().then((x) => {
const grouped: Record<string, PermissionRequest[]> = {}
for (const perm of x.data ?? []) {
if (!perm?.id || !perm.sessionID) continue
const existing = grouped[perm.sessionID]
if (existing) {
existing.push(perm)
continue
}
grouped[perm.sessionID] = [perm]
sdk.question.list().then((x) => {
const grouped: Record<string, QuestionRequest[]> = {}
for (const question of x.data ?? []) {
if (!question?.id || !question.sessionID) continue
const existing = grouped[question.sessionID]
if (existing) {
existing.push(question)
continue
}
grouped[question.sessionID] = [question]
}
batch(() => {
for (const sessionID of Object.keys(store.permission)) {
if (grouped[sessionID]) continue
setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
setStore(
"permission",
sessionID,
reconcile(
permissions
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
})
}),
sdk.question.list().then((x) => {
const grouped: Record<string, QuestionRequest[]> = {}
for (const question of x.data ?? []) {
if (!question?.id || !question.sessionID) continue
const existing = grouped[question.sessionID]
if (existing) {
existing.push(question)
continue
}
grouped[question.sessionID] = [question]
batch(() => {
for (const sessionID of Object.keys(store.question)) {
if (grouped[sessionID]) continue
setStore("question", sessionID, [])
}
batch(() => {
for (const sessionID of Object.keys(store.question)) {
if (grouped[sessionID]) continue
setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
setStore(
"question",
sessionID,
reconcile(
questions
.filter((q) => !!q?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
})
}),
]).then(() => {
setStore("status", "complete")
})
for (const [sessionID, questions] of Object.entries(grouped)) {
setStore(
"question",
sessionID,
reconcile(
questions
.filter((q) => !!q?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
})
}),
]).then(() => {
setStore("status", "complete")
})
.catch((e) => setGlobalStore("error", e))
})()
booting.set(directory, promise)
promise.finally(() => {
booting.delete(directory)
})
return promise
}
const unsub = globalSDK.event.listen((e) => {
@@ -324,6 +394,7 @@ function createGlobalSync() {
if (directory === "global") {
switch (event?.type) {
case "global.disposed": {
if (globalStore.reload) return
bootstrap()
break
}
@@ -345,9 +416,16 @@ function createGlobalSync() {
return
}
const [store, setStore] = child(directory)
const existing = children[directory]
if (!existing) return
const [store, setStore] = existing
switch (event.type) {
case "server.instance.disposed": {
if (globalStore.reload) {
bootstrapQueue.push(directory)
return
}
bootstrapInstance(directory)
break
}
@@ -591,6 +669,11 @@ function createGlobalSync() {
setGlobalStore("path", x.data!)
}),
),
retry(() =>
globalSDK.client.config.get().then((x) => {
setGlobalStore("config", x.data!)
}),
),
retry(() =>
globalSDK.client.project.list().then(async (x) => {
const projects = (x.data ?? [])
@@ -631,6 +714,7 @@ function createGlobalSync() {
return {
data: globalStore,
set: setGlobalStore,
get ready() {
return globalStore.ready
},
@@ -639,6 +723,14 @@ function createGlobalSync() {
},
child,
bootstrap,
updateConfig: async (config: Config) => {
setGlobalStore("reload", "pending")
const response = await globalSDK.client.config.update({ config })
setTimeout(() => {
setGlobalStore("reload", "complete")
}, 1000)
return response
},
project: {
loadSessions,
},
+55 -51
View File
@@ -1,5 +1,5 @@
import { createStore, produce } from "solid-js/store"
import { batch, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
import { batch, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk"
@@ -33,8 +33,6 @@ type SessionTabs = {
type SessionView = {
scroll: Record<string, SessionScroll>
reviewOpen?: string[]
terminalOpened?: boolean
reviewPanelOpened?: boolean
}
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
@@ -78,9 +76,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
terminal: {
height: 280,
opened: false,
},
review: {
diffStyle: "split" as ReviewDiffStyle,
panelOpened: true,
},
session: {
width: 600,
@@ -93,8 +93,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}),
)
const [shortcutsOpened, setShortcutsOpened] = createSignal(false)
const MAX_SESSION_KEYS = 50
const meta = { active: undefined as string | undefined, pruned: false }
const used = new Map<string, number>()
@@ -174,7 +172,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const current = store.sessionView[sessionKey]
const keep = meta.active ?? sessionKey
if (!current) {
setStore("sessionView", sessionKey, { scroll: next, terminalOpened: false, reviewPanelOpened: true })
setStore("sessionView", sessionKey, { scroll: next })
prune(keep)
return
}
@@ -210,10 +208,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
})
})
const usedColors = new Set<AvatarColorKey>()
const [colors, setColors] = createStore<Record<string, AvatarColorKey>>({})
function pickAvailableColor(): AvatarColorKey {
const available = AVATAR_COLOR_KEYS.filter((c) => !usedColors.has(c))
function pickAvailableColor(used: Set<string>): AvatarColorKey {
const available = AVATAR_COLOR_KEYS.filter((c) => !used.has(c))
if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)]
return available[Math.floor(Math.random() * available.length)]
}
@@ -224,24 +222,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const metadata = projectID
? globalSync.data.project.find((x) => x.id === projectID)
: globalSync.data.project.find((x) => x.worktree === project.worktree)
return [
{
...(metadata ?? {}),
...project,
icon: { url: metadata?.icon?.url, color: metadata?.icon?.color },
return {
...(metadata ?? {}),
...project,
icon: {
url: metadata?.icon?.url,
override: metadata?.icon?.override,
color: metadata?.icon?.color,
},
]
}
function colorize(project: LocalProject) {
if (project.icon?.color) return project
const color = pickAvailableColor()
usedColors.add(color)
project.icon = { ...project.icon, color }
if (project.id) {
globalSdk.client.project.update({ projectID: project.id, icon: { color } })
}
return project
}
const roots = createMemo(() => {
@@ -279,8 +268,37 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
})
})
const enriched = createMemo(() => server.projects.list().flatMap(enrich))
const list = createMemo(() => enriched().flatMap(colorize))
const enriched = createMemo(() => server.projects.list().map(enrich))
const list = createMemo(() => {
const projects = enriched()
return projects.map((project) => {
const color = project.icon?.color ?? colors[project.worktree]
if (!color) return project
const icon = project.icon ? { ...project.icon, color } : { color }
return { ...project, icon }
})
})
createEffect(() => {
const projects = enriched()
if (projects.length === 0) return
const used = new Set<string>()
for (const project of projects) {
const color = project.icon?.color ?? colors[project.worktree]
if (color) used.add(color)
}
for (const project of projects) {
if (project.icon?.color) continue
if (colors[project.worktree]) continue
const color = pickAvailableColor(used)
used.add(color)
setColors(project.worktree, color)
if (!project.id) continue
void globalSdk.client.project.update({ projectID: project.id, icon: { color } })
}
})
onMount(() => {
Promise.all(
@@ -355,18 +373,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("review", "diffStyle", diffStyle)
},
},
shortcuts: {
opened: shortcutsOpened,
open() {
setShortcutsOpened(true)
},
close() {
setShortcutsOpened(false)
},
toggle() {
setShortcutsOpened((x) => !x)
},
},
session: {
width: createMemo(() => store.session?.width ?? 600),
resize(width: number) {
@@ -393,31 +399,31 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
touch(sessionKey)
scroll.seed(sessionKey)
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
const terminalOpened = createMemo(() => s().terminalOpened ?? false)
const reviewPanelOpened = createMemo(() => s().reviewPanelOpened ?? true)
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
function setTerminalOpened(next: boolean) {
const current = store.sessionView[sessionKey]
const current = store.terminal
if (!current) {
setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: next, reviewPanelOpened: true })
setStore("terminal", { height: 280, opened: next })
return
}
const value = current.terminalOpened ?? false
const value = current.opened ?? false
if (value === next) return
setStore("sessionView", sessionKey, "terminalOpened", next)
setStore("terminal", "opened", next)
}
function setReviewPanelOpened(next: boolean) {
const current = store.sessionView[sessionKey]
const current = store.review
if (!current) {
setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: false, reviewPanelOpened: next })
setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next })
return
}
const value = current.reviewPanelOpened ?? true
const value = current.panelOpened ?? true
if (value === next) return
setStore("sessionView", sessionKey, "reviewPanelOpened", next)
setStore("review", "panelOpened", next)
}
return {
@@ -458,8 +464,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
if (!current) {
setStore("sessionView", sessionKey, {
scroll: {},
terminalOpened: false,
reviewPanelOpened: true,
reviewOpen: open,
})
return
+19 -21
View File
@@ -4,13 +4,12 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSDK } from "./global-sdk"
import { useGlobalSync } from "./global-sync"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { Binary } from "@opencode-ai/util/binary"
import { base64Encode } from "@opencode-ai/util/encode"
import { EventSessionError } from "@opencode-ai/sdk/v2"
import { makeAudioPlayer } from "@solid-primitives/audio"
import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
import errorSound from "@opencode-ai/ui/audio/nope-03.aac"
import { Persist, persisted } from "@/utils/persist"
import { playSound, soundSrc } from "@/utils/sound"
type NotificationBase = {
directory?: string
@@ -44,19 +43,10 @@ function pruneNotifications(list: Notification[]) {
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification",
init: () => {
let idlePlayer: ReturnType<typeof makeAudioPlayer> | undefined
let errorPlayer: ReturnType<typeof makeAudioPlayer> | undefined
try {
idlePlayer = makeAudioPlayer(idleSound)
errorPlayer = makeAudioPlayer(errorSound)
} catch (err) {
console.log("Failed to load audio", err)
}
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const platform = usePlatform()
const settings = useSettings()
const [store, setStore, _, ready] = persisted(
Persist.global("notification", ["notification.v1"]),
@@ -93,16 +83,20 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
const session = match.found ? syncStore.session[match.index] : undefined
if (session?.parentID) break
try {
idlePlayer?.play()
} catch {}
playSound(soundSrc(settings.sounds.agent()))
append({
...base,
type: "turn-complete",
session: sessionID,
})
const href = `/${base64Encode(directory)}/session/${sessionID}`
void platform.notify("Response ready", session?.title ?? sessionID, href)
if (settings.notifications.agent()) {
void platform.notify("Response ready", session?.title ?? sessionID, href)
}
break
}
case "session.error": {
@@ -111,9 +105,9 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const match = sessionID ? Binary.search(syncStore.session, sessionID, (s) => s.id) : undefined
const session = sessionID && match?.found ? syncStore.session[match.index] : undefined
if (session?.parentID) break
try {
errorPlayer?.play()
} catch {}
playSound(soundSrc(settings.sounds.errors()))
const error = "error" in event.properties ? event.properties.error : undefined
append({
...base,
@@ -121,9 +115,13 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
session: sessionID ?? "global",
error,
})
const description = session?.title ?? (typeof error === "string" ? error : "An error occurred")
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
void platform.notify("Session error", description, href)
if (settings.notifications.errors()) {
void platform.notify("Session error", description, href)
}
break
}
}
+158
View File
@@ -0,0 +1,158 @@
import { createStore, reconcile } from "solid-js/store"
import { createEffect, createMemo } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { persisted } from "@/utils/persist"
export interface NotificationSettings {
agent: boolean
permissions: boolean
errors: boolean
}
export interface SoundSettings {
agent: string
permissions: string
errors: string
}
export interface Settings {
general: {
autoSave: boolean
}
appearance: {
fontSize: number
font: string
}
keybinds: Record<string, string>
permissions: {
autoApprove: boolean
}
notifications: NotificationSettings
sounds: SoundSettings
}
const defaultSettings: Settings = {
general: {
autoSave: true,
},
appearance: {
fontSize: 14,
font: "ibm-plex-mono",
},
keybinds: {},
permissions: {
autoApprove: false,
},
notifications: {
agent: true,
permissions: true,
errors: false,
},
sounds: {
agent: "staplebops-01",
permissions: "staplebops-02",
errors: "nope-03",
},
}
const monoFallback =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const monoFonts: Record<string, string> = {
"ibm-plex-mono": `"IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"cascadia-code": `"Cascadia Code Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"fira-code": `"Fira Code Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
hack: `"Hack Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
inconsolata: `"Inconsolata Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"intel-one-mono": `"Intel One Mono Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"jetbrains-mono": `"JetBrains Mono Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"meslo-lgs": `"Meslo LGS Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"roboto-mono": `"Roboto Mono Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"source-code-pro": `"Source Code Pro Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"ubuntu-mono": `"Ubuntu Mono Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
}
export function monoFontFamily(font: string | undefined) {
return monoFonts[font ?? defaultSettings.appearance.font] ?? monoFonts[defaultSettings.appearance.font]
}
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
name: "Settings",
init: () => {
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
createEffect(() => {
if (typeof document === "undefined") return
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
})
return {
ready,
get current() {
return store
},
general: {
autoSave: createMemo(() => store.general?.autoSave ?? defaultSettings.general.autoSave),
setAutoSave(value: boolean) {
setStore("general", "autoSave", value)
},
},
appearance: {
fontSize: createMemo(() => store.appearance?.fontSize ?? defaultSettings.appearance.fontSize),
setFontSize(value: number) {
setStore("appearance", "fontSize", value)
},
font: createMemo(() => store.appearance?.font ?? defaultSettings.appearance.font),
setFont(value: string) {
setStore("appearance", "font", value)
},
},
keybinds: {
get: (action: string) => store.keybinds?.[action],
set(action: string, keybind: string) {
setStore("keybinds", action, keybind)
},
reset(action: string) {
setStore("keybinds", action, undefined!)
},
resetAll() {
setStore("keybinds", reconcile({}))
},
},
permissions: {
autoApprove: createMemo(() => store.permissions?.autoApprove ?? defaultSettings.permissions.autoApprove),
setAutoApprove(value: boolean) {
setStore("permissions", "autoApprove", value)
},
},
notifications: {
agent: createMemo(() => store.notifications?.agent ?? defaultSettings.notifications.agent),
setAgent(value: boolean) {
setStore("notifications", "agent", value)
},
permissions: createMemo(() => store.notifications?.permissions ?? defaultSettings.notifications.permissions),
setPermissions(value: boolean) {
setStore("notifications", "permissions", value)
},
errors: createMemo(() => store.notifications?.errors ?? defaultSettings.notifications.errors),
setErrors(value: boolean) {
setStore("notifications", "errors", value)
},
},
sounds: {
agent: createMemo(() => store.sounds?.agent ?? defaultSettings.sounds.agent),
setAgent(value: string) {
setStore("sounds", "agent", value)
},
permissions: createMemo(() => store.sounds?.permissions ?? defaultSettings.sounds.permissions),
setPermissions(value: string) {
setStore("sounds", "permissions", value)
},
errors: createMemo(() => store.sounds?.errors ?? defaultSettings.sounds.errors),
setErrors(value: string) {
setStore("sounds", "errors", value)
},
},
}
},
})
+16
View File
@@ -38,6 +38,22 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, sess
}),
)
const unsub = sdk.event.on("pty.exited", (event) => {
const id = event.properties.id
if (!store.all.some((x) => x.id === id)) return
batch(() => {
setStore(
"all",
store.all.filter((x) => x.id !== id),
)
if (store.active === id) {
const remaining = store.all.filter((x) => x.id !== id)
setStore("active", remaining[0]?.id)
}
})
})
onCleanup(unsub)
return {
ready,
all: createMemo(() => Object.values(store.all)),
+1 -1
View File
@@ -37,7 +37,7 @@ const platform: Platform = {
.then(() => {
const notification = new Notification(title, {
body: description ?? "",
icon: "https://opencode.ai/favicon-96x96.png",
icon: "https://opencode.ai/favicon-96x96-v2.png",
})
notification.onclick = () => {
window.focus()
-259
View File
@@ -9,262 +9,3 @@
*[data-tauri-drag-region] {
app-region: drag;
}
/* Wider dialog variant for release notes modal */
[data-component="dialog"]:has(.dialog-release-notes) {
padding: 20px;
box-sizing: border-box;
[data-slot="dialog-container"] {
width: min(100%, 720px);
height: min(100%, 400px);
margin-top: -80px;
[data-slot="dialog-content"] {
min-height: auto;
overflow: hidden;
height: 100%;
border: none;
box-shadow: var(--shadow-lg-border-base);
}
[data-slot="dialog-body"] {
overflow: hidden;
height: 100%;
display: flex;
flex-direction: row;
}
}
}
/* Shortcuts panel */
[data-component="shortcuts-panel"] {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--color-background-base);
border-top: 1px solid var(--color-border-weak-base);
z-index: 100;
display: flex;
flex-direction: column;
height: 280px;
}
.shortcuts-panel [data-component="tabs"] {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.shortcuts-tabs-row {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
position: relative;
padding: 12px 16px;
flex-shrink: 0;
}
.shortcuts-tabs-row > [data-component="icon-button"] {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
}
.shortcuts-panel [data-slot="tabs-list"] {
height: auto;
justify-content: center;
gap: 24px;
padding: 4px;
}
.shortcuts-panel [data-slot="tabs-list"]::after {
display: none;
}
.shortcuts-panel [data-slot="tabs-trigger-wrapper"] {
background: transparent;
border: none;
font-weight: var(--font-weight-regular);
border-radius: 6px;
color: var(--color-text-weak);
}
.shortcuts-panel [data-slot="tabs-trigger-wrapper"]:hover {
background: var(--color-surface-base);
}
.shortcuts-panel [data-slot="tabs-trigger-wrapper"]:has([data-selected]) {
background: var(--color-surface-raised-base-active);
color: var(--color-text-strong);
}
.shortcuts-panel [data-slot="tabs-trigger"] {
padding: 4px 12px;
border-radius: 6px;
}
.shortcuts-panel [data-slot="tabs-trigger"]:focus-visible {
outline: none;
box-shadow: var(--shadow-xs-border-focus);
}
.shortcuts-panel [data-slot="tabs-content"] {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
justify-content: center;
}
.shortcuts-grid {
display: grid;
grid-template-columns: repeat(4, 240px);
gap: 4px 48px;
align-content: start;
}
@media (max-width: 1200px) {
.shortcuts-grid {
grid-template-columns: repeat(3, 240px);
gap: 4px 32px;
}
.shortcuts-panel [data-slot="tabs-list"] {
gap: 16px;
}
}
@media (max-width: 900px) {
.shortcuts-grid {
grid-template-columns: repeat(2, 240px);
gap: 4px 24px;
}
.shortcuts-panel [data-slot="tabs-list"] {
gap: 8px;
}
}
@media (max-width: 600px) {
.shortcuts-grid {
grid-template-columns: repeat(2, minmax(160px, 1fr));
gap: 4px 24px;
}
.shortcuts-panel [data-slot="tabs-list"] {
gap: 4px;
}
.shortcuts-panel [data-slot="tabs-trigger"] {
padding: 4px 8px;
}
}
@media (max-width: 420px) {
.shortcuts-grid {
grid-template-columns: 1fr;
}
}
.shortcut-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 6px 6px 12px;
margin: 0 -12px;
gap: 16px;
height: 36px;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.1s ease;
text-align: left;
width: calc(100% + 24px);
box-sizing: border-box;
}
.shortcut-item:hover {
background: var(--color-surface-base);
}
.shortcut-item:hover .shortcut-key {
background: var(--color-background-stronger);
}
.shortcut-item:active {
background: var(--color-surface-raised-base);
}
.shortcut-item.shortcut-used span {
color: var(--color-text-interactive-base);
}
.shortcut-item.shortcut-used .shortcut-key {
color: var(--color-text-interactive-base);
border-color: var(--color-border-interactive-base);
background: var(--color-surface-interactive-base);
box-shadow: var(--shadow-xs-border-interactive);
}
.shortcut-keys {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.shortcut-copied {
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-success-base);
margin-right: 4px;
}
.shortcut-key {
display: flex;
align-items: center;
justify-content: center;
min-width: 24px;
height: 24px;
padding: 0 6px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--color-text-subtle);
box-shadow: var(--shadow-xs-border-base);
border-radius: 4px;
white-space: nowrap;
}
.shortcut-key-special {
font-size: 18px;
}
.shortcut-key-shift {
position: relative;
top: -2px;
}
/* Adjust main content and sidebar when shortcuts panel is open */
main.shortcuts-open {
padding-bottom: 280px;
}
.sidebar-shortcuts-open {
padding-bottom: 280px;
}
/* Adjust dialogs when shortcuts panel is open */
body:has([data-component="shortcuts-panel"]) [data-component="dialog"] {
bottom: 280px;
}
body:has([data-component="shortcuts-panel"]) [data-component="dialog-overlay"] {
bottom: 280px;
}
-53
View File
@@ -1,53 +0,0 @@
import { CURRENT_RELEASE } from "@/components/dialog-release-notes"
const STORAGE_KEY = "opencode:last-seen-version"
// ============================================================================
// DEV MODE: Set this to true to always show the release notes modal on startup
// Set to false for production behavior (only shows after updates)
// ============================================================================
const DEV_ALWAYS_SHOW_RELEASE_NOTES = true
/**
* Check if release notes should be shown
* Returns true if:
* - DEV_ALWAYS_SHOW_RELEASE_NOTES is true (for development)
* - OR the current version is newer than the last seen version
*/
export function shouldShowReleaseNotes(): boolean {
if (DEV_ALWAYS_SHOW_RELEASE_NOTES) {
console.log("[ReleaseNotes] DEV mode: always showing release notes")
return true
}
const lastSeen = localStorage.getItem(STORAGE_KEY)
if (!lastSeen) {
// First time user - show release notes
return true
}
// Compare versions - show if current is newer
return CURRENT_RELEASE.version !== lastSeen
}
/**
* Mark the current release notes as seen
* Call this when the user closes the release notes modal
*/
export function markReleaseNotesSeen(): void {
localStorage.setItem(STORAGE_KEY, CURRENT_RELEASE.version)
}
/**
* Get the current version
*/
export function getCurrentVersion(): string {
return CURRENT_RELEASE.version
}
/**
* Reset the seen status (useful for testing)
*/
export function resetReleaseNotesSeen(): void {
localStorage.removeItem(STORAGE_KEY)
}
+262 -160
View File
@@ -37,6 +37,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { getFilename } from "@opencode-ai/util/path"
import { Session, type Message, type TextPart } from "@opencode-ai/sdk/v2/client"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { createStore, produce, reconcile } from "solid-js/store"
import {
DragDropProvider,
@@ -54,18 +55,18 @@ import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { playSound, soundSrc } from "@/utils/sound"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { DialogSelectProvider } from "@/components/dialog-select-provider"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
import { DialogSettings } from "@/components/dialog-settings"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { navStart } from "@/utils/perf"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogEditProject } from "@/components/dialog-edit-project"
import { ReleaseNotesHandler } from "@/components/release-notes-handler"
import { ShortcutsPanel } from "@/components/shortcuts-panel"
import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server"
@@ -78,6 +79,7 @@ export default function Layout(props: ParentProps) {
activeWorkspace: undefined as string | undefined,
workspaceOrder: {} as Record<string, string[]>,
workspaceName: {} as Record<string, string>,
workspaceBranchName: {} as Record<string, Record<string, string>>,
workspaceExpanded: {} as Record<string, boolean>,
}),
)
@@ -87,7 +89,6 @@ export default function Layout(props: ParentProps) {
let scrollContainerRef: HTMLDivElement | undefined
const xlQuery = window.matchMedia("(min-width: 1280px)")
const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches)
const [helpDropdownWidth, setHelpDropdownWidth] = createSignal<number | undefined>(undefined)
const handleViewportChange = (e: MediaQueryListEvent) => setIsLargeViewport(e.matches)
xlQuery.addEventListener("change", handleViewportChange)
onCleanup(() => xlQuery.removeEventListener("change", handleViewportChange))
@@ -99,6 +100,7 @@ export default function Layout(props: ParentProps) {
const layout = useLayout()
const layoutReady = createMemo(() => layout.ready())
const platform = usePlatform()
const settings = useSettings()
const server = useServer()
const notification = useNotification()
const permission = usePermission()
@@ -120,15 +122,36 @@ export default function Layout(props: ParentProps) {
active: "" as string,
value: "",
})
const [busyWorkspaces, setBusyWorkspaces] = createSignal<Set<string>>(new Set())
const setBusy = (directory: string, value: boolean) => {
const key = workspaceKey(directory)
setBusyWorkspaces((prev) => {
const next = new Set(prev)
if (value) next.add(key)
else next.delete(key)
return next
})
}
const isBusy = (directory: string) => busyWorkspaces().has(workspaceKey(directory))
const editorRef = { current: undefined as HTMLInputElement | undefined }
const autoselecting = createMemo(() => {
if (params.dir) return false
if (initialDir) return false
if (!autoselect()) return false
if (!pageReady()) return true
if (!layoutReady()) return true
const list = layout.projects.list()
if (list.length === 0) return false
return true
})
const editorOpen = (id: string) => editor.active === id
const editorValue = () => editor.value
const openEditor = (id: string, value: string) => {
if (!id) return
setEditor({ active: id, value })
queueMicrotask(() => editorRef.current?.focus())
}
const closeEditor = () => setEditor({ active: "", value: "" })
@@ -197,11 +220,15 @@ export default function Layout(props: ParentProps) {
<InlineInput
ref={(el) => {
editorRef.current = el
requestAnimationFrame(() => el.focus())
}}
value={editorValue()}
class={props.class}
onInput={(event) => setEditor("value", event.currentTarget.value)}
onKeyDown={(event) => editorKeyDown(event, props.onSave)}
onKeyDown={(event) => {
event.stopPropagation()
editorKeyDown(event, props.onSave)
}}
onBlur={() => closeEditor()}
onPointerDown={stopPropagation}
onClick={stopPropagation}
@@ -316,7 +343,18 @@ export default function Layout(props: ParentProps) {
if (now - lastAlerted < cooldownMs) return
alertedAtBySession.set(sessionKey, now)
void platform.notify(config.title, description, href)
if (e.details.type === "permission.asked") {
playSound(soundSrc(settings.sounds.permissions()))
if (settings.notifications.permissions()) {
void platform.notify(config.title, description, href)
}
}
if (e.details.type === "question.asked") {
if (settings.notifications.agent()) {
void platform.notify(config.title, description, href)
}
}
const currentDir = params.dir ? base64Decode(params.dir) : undefined
const currentSession = params.id
@@ -461,9 +499,27 @@ export default function Layout(props: ParentProps) {
),
)
const workspaceName = (directory: string) => store.workspaceName[directory]
const workspaceLabel = (directory: string, branch?: string) =>
workspaceName(directory) ?? branch ?? getFilename(directory)
const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "")
const workspaceName = (directory: string, projectId?: string, branch?: string) => {
const key = workspaceKey(directory)
const direct = store.workspaceName[key] ?? store.workspaceName[directory]
if (direct) return direct
if (!projectId) return
if (!branch) return
return store.workspaceBranchName[projectId]?.[branch]
}
const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => {
const key = workspaceKey(directory)
setStore("workspaceName", (prev) => ({ ...(prev ?? {}), [key]: next }))
if (!projectId) return
if (!branch) return
setStore("workspaceBranchName", projectId, (prev) => ({ ...(prev ?? {}), [branch]: next }))
}
const workspaceLabel = (directory: string, branch?: string, projectId?: string) =>
workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
const isWorkspaceEditing = () => editor.active.startsWith("workspace:")
@@ -518,9 +574,13 @@ export default function Layout(props: ParentProps) {
if (!project) return [] as Session[]
if (workspaceSetting()) {
const dirs = workspaceIds(project)
const activeDir = params.dir ? base64Decode(params.dir) : ""
const result: Session[] = []
for (const dir of dirs) {
const [dirStore] = globalSync.child(dir)
const expanded = store.workspaceExpanded[dir] ?? dir === project.worktree
const active = dir === activeDir
if (!expanded && !active) continue
const [dirStore] = globalSync.child(dir, { bootstrap: true })
const dirSessions = dirStore.session
.filter((session) => session.directory === dirStore.path.directory)
.filter((session) => !session.parentID && !session.time?.archived)
@@ -543,7 +603,7 @@ export default function Layout(props: ParentProps) {
running: number
}
const prefetchChunk = 200
const prefetchChunk = 600
const prefetchConcurrency = 1
const prefetchPendingLimit = 6
const prefetchToken = { value: 0 }
@@ -752,13 +812,6 @@ export default function Layout(props: ParentProps) {
keybind: "mod+b",
onSelect: () => layout.sidebar.toggle(),
},
{
id: "shortcuts.toggle",
title: "Toggle shortcuts panel",
category: "View",
keybind: "ctrl+/",
onSelect: () => layout.shortcuts.toggle(),
},
{
id: "project.open",
title: "Open project",
@@ -857,6 +910,10 @@ export default function Layout(props: ParentProps) {
dialog.show(() => <DialogSelectServer />)
}
function openSettings() {
dialog.show(() => <DialogSettings />)
}
function navigateToProject(directory: string | undefined) {
if (!directory) return
server.projects.touch(directory)
@@ -895,10 +952,10 @@ export default function Layout(props: ParentProps) {
})
}
const renameWorkspace = (directory: string, next: string) => {
const current = workspaceName(directory) ?? getFilename(directory)
const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => {
const current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
if (current === next) return
setStore("workspaceName", directory, next)
setWorkspaceName(directory, next, projectId, branch)
}
function closeProject(directory: string) {
@@ -949,6 +1006,8 @@ export default function Layout(props: ParentProps) {
if (!current) return
if (directory === current.worktree) return
setBusy(directory, true)
const result = await globalSDK.client.worktree
.remove({ directory: current.worktree, worktreeRemoveInput: { directory } })
.then((x) => x.data)
@@ -960,6 +1019,8 @@ export default function Layout(props: ParentProps) {
return false
})
setBusy(directory, false)
if (!result) return
layout.projects.close(directory)
@@ -975,12 +1036,7 @@ export default function Layout(props: ParentProps) {
if (!current) return
if (directory === current.worktree) return
const progress = showToast({
persistent: true,
title: "Resetting workspace",
description: "This may take a minute.",
})
const dismiss = () => toaster.dismiss(progress)
setBusy(directory, true)
const sessions = await globalSDK.client.session
.list({ directory })
@@ -991,7 +1047,6 @@ export default function Layout(props: ParentProps) {
.reset({ directory: current.worktree, worktreeResetInput: { directory } })
.then((x) => x.data)
.catch((err) => {
dismiss()
showToast({
title: "Failed to reset workspace",
description: errorMessage(err),
@@ -1000,7 +1055,7 @@ export default function Layout(props: ParentProps) {
})
if (!result) {
dismiss()
setBusy(directory, false)
return
}
@@ -1020,7 +1075,8 @@ export default function Layout(props: ParentProps) {
)
await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
dismiss()
setBusy(directory, false)
const href = `/${base64Encode(directory)}/session`
navigate(href)
@@ -1191,18 +1247,17 @@ export default function Layout(props: ParentProps) {
),
)
createEffect(() => {
const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
})
createEffect(() => {
const project = currentProject()
if (!project) return
if (workspaceSetting()) {
const activeDir = params.dir ? base64Decode(params.dir) : ""
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
for (const directory of dirs) {
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
const active = directory === activeDir
if (!expanded && !active) continue
globalSync.project.loadSessions(directory)
}
return
@@ -1299,7 +1354,7 @@ export default function Layout(props: ParentProps) {
<div class="size-full rounded overflow-clip">
<Avatar
fallback={name()}
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.override}
{...getAvatarColors(props.project.icon?.color)}
class="size-full rounded"
style={
@@ -1426,7 +1481,7 @@ export default function Layout(props: ParentProps) {
<div
data-session-id={props.session.id}
class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
>
<Show
when={hoverEnabled()}
@@ -1436,7 +1491,7 @@ export default function Layout(props: ParentProps) {
</Tooltip>
}
>
<HoverCard openDelay={150} closeDelay={100} placement="right" gutter={12} trigger={item}>
<HoverCard openDelay={1000} closeDelay={0} placement="right-start" gutter={28} trigger={item}>
<Show when={hoverReady()} fallback={<div class="text-12-regular text-text-weak">Loading messages</div>}>
<MessageNav
messages={hoverMessages() ?? []}
@@ -1444,10 +1499,11 @@ export default function Layout(props: ParentProps) {
getLabel={messageLabel}
onMessageSelect={(message) => {
if (!isActive()) {
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
sessionStorage.setItem("opencode.pendingMessage", `${props.session.id}|${message.id}`)
navigate(`${props.slug}/session/${props.session.id}`)
return
}
window.location.hash = `message-${message.id}`
window.history.replaceState(null, "", `#message-${message.id}`)
window.dispatchEvent(new HashChangeEvent("hashchange"))
}}
size="normal"
@@ -1505,7 +1561,7 @@ export default function Layout(props: ParentProps) {
const [workspaceStore] = globalSync.child(directory)
const kind = directory === project.worktree ? "local" : "sandbox"
const name = workspaceLabel(directory, workspaceStore.vcs?.branch)
const name = workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
return `${kind} : ${name}`
})
@@ -1520,8 +1576,9 @@ export default function Layout(props: ParentProps) {
const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => {
const sortable = createSortable(props.directory)
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory)
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false })
const [menuOpen, setMenuOpen] = createSignal(false)
const [pendingRename, setPendingRename] = createSignal(false)
const slug = createMemo(() => base64Encode(props.directory))
const sessions = createMemo(() =>
workspaceStore.session
@@ -1530,13 +1587,20 @@ export default function Layout(props: ParentProps) {
.toSorted(sortSessions),
)
const local = createMemo(() => props.directory === props.project.worktree)
const workspaceValue = createMemo(() => {
const name = workspaceStore.vcs?.branch ?? getFilename(props.directory)
return workspaceName(props.directory) ?? name
const active = createMemo(() => {
const current = params.dir ? base64Decode(params.dir) : ""
return current === props.directory
})
const open = createMemo(() => store.workspaceExpanded[props.directory] ?? true)
const workspaceValue = createMemo(() => {
const branch = workspaceStore.vcs?.branch
const name = branch ?? getFilename(props.directory)
return workspaceName(props.directory, props.project.id, branch) ?? name
})
const open = createMemo(() => store.workspaceExpanded[props.directory] ?? local())
const boot = createMemo(() => open() || active())
const loading = createMemo(() => open() && workspaceStore.status !== "complete" && sessions().length === 0)
const hasMore = createMemo(() => local() && workspaceStore.sessionTotal > workspaceStore.session.length)
const busy = createMemo(() => isBusy(props.directory))
const loadMore = async () => {
if (!local()) return
setWorkspaceStore("limit", (limit) => limit + 5)
@@ -1551,50 +1615,74 @@ export default function Layout(props: ParentProps) {
if (editorOpen(`workspace:${props.directory}`)) closeEditor()
}
createEffect(() => {
if (!boot()) return
globalSync.child(props.directory, { bootstrap: true })
})
const header = () => (
<div class="flex items-center gap-1 min-w-0 flex-1">
<div class="flex items-center justify-center shrink-0 size-6">
<Show when={busy()} fallback={<Icon name="branch" size="small" />}>
<Spinner class="size-[15px]" />
</Show>
</div>
<span class="text-14-medium text-text-base shrink-0">{local() ? "local" : "sandbox"} :</span>
<Show
when={!local()}
fallback={
<span class="text-14-medium text-text-base min-w-0 truncate">
{workspaceStore.vcs?.branch ?? getFilename(props.directory)}
</span>
}
>
<InlineEditor
id={`workspace:${props.directory}`}
value={workspaceValue}
onSave={(next) => {
const trimmed = next.trim()
if (!trimmed) return
renameWorkspace(props.directory, trimmed, props.project.id, workspaceStore.vcs?.branch)
setEditor("value", workspaceValue())
}}
class="text-14-medium text-text-base min-w-0 truncate"
displayClass="text-14-medium text-text-base min-w-0 truncate"
editing={workspaceEditActive()}
stopPropagation={false}
openOnDblClick={false}
/>
</Show>
<Icon
name={open() ? "chevron-down" : "chevron-right"}
size="small"
class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/workspace:opacity-100 group-focus-within/workspace:opacity-100"
/>
</div>
)
return (
// @ts-ignore
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<div
// @ts-ignore
use:sortable
classList={{
"opacity-30": sortable.isActiveDraggable,
"opacity-50 pointer-events-none": busy(),
}}
>
<Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
<div class="px-2 py-1">
<div class="group/workspace relative">
<div class="flex items-center gap-1">
<Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover">
<div class="flex items-center gap-1 min-w-0 flex-1">
<div class="flex items-center justify-center shrink-0 size-6">
<Icon name="branch" size="small" />
</div>
<span class="text-14-medium text-text-base shrink-0">{local() ? "local" : "sandbox"} :</span>
<Show
when={!local()}
fallback={
<span class="text-14-medium text-text-base min-w-0 truncate">
{workspaceStore.vcs?.branch ?? getFilename(props.directory)}
</span>
}
>
<InlineEditor
id={`workspace:${props.directory}`}
value={workspaceValue}
onSave={(next) => {
const trimmed = next.trim()
if (!trimmed) return
renameWorkspace(props.directory, trimmed)
setEditor("value", workspaceValue())
}}
class="text-14-medium text-text-base min-w-0 truncate"
displayClass="text-14-medium text-text-base min-w-0 truncate"
editing={workspaceEditActive()}
stopPropagation={false}
openOnDblClick={false}
/>
</Show>
<Icon
name={open() ? "chevron-down" : "chevron-right"}
size="small"
class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/workspace:opacity-100 group-focus-within/workspace:opacity-100"
/>
</div>
</Collapsible.Trigger>
<Show
when={workspaceEditActive()}
fallback={
<Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover">
{header()}
</Collapsible.Trigger>
}
>
<div class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md">{header()}</div>
</Show>
<div
class="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 transition-opacity"
classList={{
@@ -1609,21 +1697,37 @@ export default function Layout(props: ParentProps) {
<DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" class="size-6 rounded-md" />
</Tooltip>
<DropdownMenu.Portal>
<DropdownMenu.Content>
<DropdownMenu.Content
onCloseAutoFocus={(event) => {
if (!pendingRename()) return
event.preventDefault()
setPendingRename(false)
openEditor(`workspace:${props.directory}`, workspaceValue())
}}
>
<DropdownMenu.Item onSelect={() => navigate(`/${slug()}/session`)}>
<DropdownMenu.ItemLabel>New session</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
disabled={local()}
onSelect={() => dialog.show(() => <DialogResetWorkspace directory={props.directory} />)}
onSelect={() => {
setPendingRename(true)
setMenuOpen(false)
}}
>
<DropdownMenu.ItemLabel>Reset workspace</DropdownMenu.ItemLabel>
<DropdownMenu.ItemLabel>Rename</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
disabled={local()}
disabled={local() || busy()}
onSelect={() => dialog.show(() => <DialogResetWorkspace directory={props.directory} />)}
>
<DropdownMenu.ItemLabel>Reset</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
disabled={local() || busy()}
onSelect={() => dialog.show(() => <DialogDeleteWorkspace directory={props.directory} />)}
>
<DropdownMenu.ItemLabel>Delete workspace</DropdownMenu.ItemLabel>
<DropdownMenu.ItemLabel>Delete</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
@@ -1695,7 +1799,7 @@ export default function Layout(props: ParentProps) {
const label = (directory: string) => {
const [data] = globalSync.child(directory)
const kind = directory === props.project.worktree ? "local" : "sandbox"
const name = workspaceLabel(directory, data.vcs?.branch)
const name = workspaceLabel(directory, data.vcs?.branch, props.project.id)
return `${kind} : ${name}`
}
@@ -1737,6 +1841,7 @@ export default function Layout(props: ParentProps) {
// @ts-ignore
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<HoverCard
open={open()}
openDelay={0}
closeDelay={0}
placement="right-start"
@@ -1744,7 +1849,7 @@ export default function Layout(props: ParentProps) {
trigger={trigger}
onOpenChange={setOpen}
>
<div class="-m-3 flex flex-col w-72">
<div class="-m-3 p-2 flex flex-col w-72">
<div class="px-4 pt-2 pb-1 text-14-medium text-text-strong truncate">{displayName(props.project)}</div>
<div class="px-4 pb-2 text-12-medium text-text-weak">Recent sessions</div>
<div class="px-2 pb-2 flex flex-col gap-2">
@@ -1789,20 +1894,22 @@ export default function Layout(props: ParentProps) {
</For>
</Show>
</div>
<Show when={!selected()}>
<div class="px-2 py-2 border-t border-border-weak-base">
<Button
variant="ghost"
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
onClick={() => {
layout.sidebar.open()
navigateToProject(props.project.worktree)
}}
>
View all sessions
</Button>
</div>
</Show>
<div class="px-2 py-2 border-t border-border-weak-base">
<Button
variant="ghost"
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
onClick={() => {
if (selected()) {
setOpen(false)
return
}
layout.sidebar.open()
navigateToProject(props.project.worktree)
}}
>
View all sessions
</Button>
</div>
</div>
</HoverCard>
</div>
@@ -1894,10 +2001,21 @@ export default function Layout(props: ParentProps) {
navigate(`/${base64Encode(created.directory)}/session`)
}
command.register(() => [
{
id: "workspace.new",
title: "New workspace",
category: "Workspace",
keybind: "mod+shift+w",
disabled: !layout.sidebar.workspaces(project()?.worktree ?? "")(),
onSelect: createWorkspace,
},
])
const homedir = createMemo(() => sync.data.path.home)
return (
<div class="flex h-full w-full overflow-hidden" classList={{ "sidebar-shortcuts-open": layout.shortcuts.opened() }}>
<div class="flex h-full w-full overflow-hidden">
<div class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden">
<div class="flex-1 min-h-0 w-full">
<DragDropProvider
@@ -1934,38 +2052,17 @@ export default function Layout(props: ParentProps) {
</DragDropProvider>
</div>
<div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2">
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Settings" class="hidden">
<IconButton disabled icon="settings-gear" variant="ghost" size="large" />
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Settings">
<IconButton icon="settings-gear" variant="ghost" size="large" onClick={openSettings} />
</Tooltip>
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Help">
<IconButton
icon="help"
variant="ghost"
size="large"
onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
/>
</Tooltip>
<DropdownMenu
placement="top-start"
gutter={8}
onOpenChange={(open) => {
if (open && layout.sidebar.opened()) {
setHelpDropdownWidth(layout.sidebar.width() - 16)
return
}
if (!open) setHelpDropdownWidth(undefined)
}}
>
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Help">
<DropdownMenu.Trigger as={IconButton} icon="question-mark" variant="ghost" size="large" />
</Tooltip>
<DropdownMenu.Portal>
<DropdownMenu.Content
style={{
width: helpDropdownWidth() ? `${helpDropdownWidth()}px` : undefined,
}}
>
<DropdownMenu.Item onSelect={() => platform.openLink("https://opencode.ai/desktop-feedback")}>
Submit feedback
</DropdownMenu.Item>
<DropdownMenu.Item class="flex justify-between gap-6" onSelect={() => layout.shortcuts.toggle()}>
Keyboard shortcuts <span class="text-text-weaker">{formatKeybind("ctrl+/")}</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
@@ -2002,7 +2099,7 @@ export default function Layout(props: ParentProps) {
transform: "translate3d(52px, 0, 0)",
}}
>
<span class="text-12-regular text-text-base truncate">
<span class="text-12-regular text-text-base truncate select-text">
{project()?.worktree.replace(homedir(), "~")}
</span>
</Tooltip>
@@ -2040,17 +2137,19 @@ export default function Layout(props: ParentProps) {
fallback={
<>
<div class="py-4 px-3">
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => {
navigate(`/${base64Encode(p.worktree)}/session`)
layout.mobileSidebar.hide()
}}
>
New session
</Button>
<TooltipKeybind title="New session" keybind={command.keybind("session.new")} placement="top">
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => {
navigate(`/${base64Encode(p.worktree)}/session`)
layout.mobileSidebar.hide()
}}
>
New session
</Button>
</TooltipKeybind>
</div>
<div class="flex-1 min-h-0">
<LocalWorkspace project={p} mobile={sidebarProps.mobile} />
@@ -2060,9 +2159,15 @@ export default function Layout(props: ParentProps) {
>
<>
<div class="py-4 px-3">
<Button size="large" icon="plus-small" class="w-full" onClick={createWorkspace}>
New workspace
</Button>
<TooltipKeybind
title="New workspace"
keybind={command.keybind("workspace.new")}
placement="top"
>
<Button size="large" icon="plus-small" class="w-full" onClick={createWorkspace}>
New workspace
</Button>
</TooltipKeybind>
</div>
<div class="relative flex-1 min-h-0">
<DragDropProvider
@@ -2176,17 +2281,14 @@ export default function Layout(props: ParentProps) {
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base": true,
"xl:border-l xl:rounded-tl-sm": !layout.sidebar.opened(),
"shortcuts-open": layout.shortcuts.opened(),
}}
>
{props.children}
<Show when={!autoselecting()} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
</div>
<Show when={layout.shortcuts.opened()}>
<ShortcutsPanel onClose={() => layout.shortcuts.close()} />
</Show>
<Toast.Region />
<ReleaseNotesHandler />
</div>
)
}
+148 -42
View File
@@ -1,4 +1,4 @@
import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on, createSignal } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web"
@@ -167,6 +167,7 @@ export default function Page() {
const sdk = useSDK()
const prompt = usePrompt()
const permission = usePermission()
const [pendingMessage, setPendingMessage] = createSignal<string | undefined>(undefined)
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
const view = createMemo(() => layout.view(sessionKey()))
@@ -384,6 +385,19 @@ export default function Page() {
terminal.new()
})
createEffect(
on(
() => terminal.all().length,
(count, prevCount) => {
if (prevCount !== undefined && prevCount > 0 && count === 0) {
if (view().terminal.opened()) {
view().terminal.toggle()
}
}
},
),
)
createEffect(
on(
() => visibleUserMessages().at(-1)?.id,
@@ -455,7 +469,7 @@ export default function Page() {
title: "New terminal",
description: "Create a new terminal tab",
category: "Terminal",
keybind: "ctrl+shift+`",
keybind: "ctrl+alt+t",
onSelect: () => terminal.new(),
},
{
@@ -530,7 +544,7 @@ export default function Page() {
title: "Cycle thinking effort",
description: "Switch to the next effort level",
category: "Model",
keybind: "shift+mod+t",
keybind: "shift+mod+d",
onSelect: () => {
local.model.variant.cycle()
},
@@ -823,10 +837,22 @@ export default function Page() {
})
const isWorking = createMemo(() => status().type !== "idle")
const autoScroll = createAutoScroll({
working: isWorking,
working: () => true,
})
createEffect(
on(
isWorking,
(working, prev) => {
if (!working || prev) return
autoScroll.forceScrollToBottom()
},
{ defer: true },
),
)
let scrollSpyFrame: number | undefined
let scrollSpyTarget: HTMLDivElement | undefined
@@ -943,17 +969,30 @@ export default function Page() {
window.history.replaceState(null, "", `#${anchor(id)}`)
}
createEffect(() => {
const sessionID = params.id
if (!sessionID) return
const raw = sessionStorage.getItem("opencode.pendingMessage")
if (!raw) return
const parts = raw.split("|")
const pendingSessionID = parts[0]
const messageID = parts[1]
if (!pendingSessionID || !messageID) return
if (pendingSessionID !== sessionID) return
sessionStorage.removeItem("opencode.pendingMessage")
setPendingMessage(messageID)
})
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
const root = scroller
if (!root) {
el.scrollIntoView({ behavior, block: "start" })
return
}
if (!root) return false
const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect()
const top = a.top - b.top + root.scrollTop
root.scrollTo({ top, behavior })
return true
}
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
@@ -967,7 +1006,15 @@ export default function Page() {
requestAnimationFrame(() => {
const el = document.getElementById(anchor(message.id))
if (el) scrollToElement(el, behavior)
if (!el) {
requestAnimationFrame(() => {
const next = document.getElementById(anchor(message.id))
if (!next) return
scrollToElement(next, behavior)
})
return
}
scrollToElement(el, behavior)
})
updateHash(message.id)
@@ -975,10 +1022,57 @@ export default function Page() {
}
const el = document.getElementById(anchor(message.id))
if (el) scrollToElement(el, behavior)
if (!el) {
updateHash(message.id)
requestAnimationFrame(() => {
const next = document.getElementById(anchor(message.id))
if (!next) return
if (!scrollToElement(next, behavior)) return
})
return
}
if (scrollToElement(el, behavior)) {
updateHash(message.id)
return
}
requestAnimationFrame(() => {
const next = document.getElementById(anchor(message.id))
if (!next) return
if (!scrollToElement(next, behavior)) return
})
updateHash(message.id)
}
const applyHash = (behavior: ScrollBehavior) => {
const hash = window.location.hash.slice(1)
if (!hash) {
autoScroll.forceScrollToBottom()
return
}
const match = hash.match(/^message-(.+)$/)
if (match) {
const msg = visibleUserMessages().find((m) => m.id === match[1])
if (msg) {
scrollToMessage(msg, behavior)
return
}
// If we have a message hash but the message isn't loaded/rendered yet,
// don't fall back to "bottom". We'll retry once messages arrive.
return
}
const target = document.getElementById(hash)
if (target) {
scrollToElement(target, behavior)
return
}
autoScroll.forceScrollToBottom()
}
const getActiveMessageId = (container: HTMLDivElement) => {
const cutoff = container.scrollTop + 100
const nodes = container.querySelectorAll<HTMLElement>("[data-message-id]")
@@ -1019,31 +1113,47 @@ export default function Page() {
if (!sessionID || !ready) return
requestAnimationFrame(() => {
const hash = window.location.hash.slice(1)
if (!hash) {
autoScroll.forceScrollToBottom()
return
}
const hashTarget = document.getElementById(hash)
if (hashTarget) {
scrollToElement(hashTarget, "auto")
return
}
const match = hash.match(/^message-(.+)$/)
if (match) {
const msg = visibleUserMessages().find((m) => m.id === match[1])
if (msg) {
scrollToMessage(msg, "auto")
return
}
}
autoScroll.forceScrollToBottom()
applyHash("auto")
})
})
// Retry message navigation once the target message is actually loaded.
createEffect(() => {
const sessionID = params.id
const ready = messagesReady()
if (!sessionID || !ready) return
// dependencies
visibleUserMessages().length
store.turnStart
const targetId =
pendingMessage() ??
(() => {
const hash = window.location.hash.slice(1)
const match = hash.match(/^message-(.+)$/)
if (!match) return undefined
return match[1]
})()
if (!targetId) return
if (store.messageId === targetId) return
const msg = visibleUserMessages().find((m) => m.id === targetId)
if (!msg) return
if (pendingMessage() === targetId) setPendingMessage(undefined)
requestAnimationFrame(() => scrollToMessage(msg, "auto"))
})
createEffect(() => {
const sessionID = params.id
const ready = messagesReady()
if (!sessionID || !ready) return
const handler = () => requestAnimationFrame(() => applyHash("auto"))
window.addEventListener("hashchange", handler)
onCleanup(() => window.removeEventListener("hashchange", handler))
})
createEffect(() => {
document.addEventListener("keydown", handleKeyDown)
})
@@ -1163,9 +1273,9 @@ export default function Page() {
</Show>
</Match>
<Match when={true}>
<div class="px-4 pt-18 pb-6 flex flex-col items-center justify-center text-center gap-3">
<Mark class="w-6 opacity-40" />
<div class="text-13-regular text-text-weak max-w-56">No changes in this session yet.</div>
<div class="h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">No changes in this session yet</div>
</div>
</Match>
</Switch>
@@ -1255,10 +1365,6 @@ export default function Page() {
classList={{
"min-w-0 w-full max-w-full": true,
"md:max-w-200": !showTabs(),
"last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-64px)]":
platform.platform !== "desktop",
"last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-64px)]":
platform.platform === "desktop",
}}
>
<SessionTurn
@@ -1439,9 +1545,9 @@ export default function Page() {
</Show>
</Match>
<Match when={true}>
<div class="px-6 pt-18 pb-6 flex flex-col items-center justify-center text-center gap-3">
<Mark class="w-6 opacity-40" />
<div class="text-13-regular text-text-weak max-w-56">No changes in this session yet.</div>
<div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">No changes in this session yet</div>
</div>
</Match>
</Switch>
+101 -7
View File
@@ -16,11 +16,15 @@ type PersistTarget = {
const LEGACY_STORAGE = "default.dat"
const GLOBAL_STORAGE = "opencode.global.dat"
const LOCAL_PREFIX = "opencode."
const fallback = { disabled: false }
const cache = new Map<string, string>()
function quota(error: unknown) {
if (error instanceof DOMException) {
if (error.name === "QuotaExceededError") return true
if (error.name === "NS_ERROR_DOM_QUOTA_REACHED") return true
if (error.name === "QUOTA_EXCEEDED_ERR") return true
if (error.code === 22 || error.code === 1014) return true
return false
}
@@ -28,13 +32,53 @@ function quota(error: unknown) {
if (!error || typeof error !== "object") return false
const name = (error as { name?: string }).name
if (name === "QuotaExceededError" || name === "NS_ERROR_DOM_QUOTA_REACHED") return true
if (name && /quota/i.test(name)) return true
const code = (error as { code?: number }).code
if (code === 22 || code === 1014) return true
const message = (error as { message?: string }).message
if (typeof message !== "string") return false
if (/quota/i.test(message)) return true
return false
}
type Evict = { key: string; size: number }
function evict(storage: Storage, keep: string, value: string) {
const total = storage.length
const indexes = Array.from({ length: total }, (_, index) => index)
const items: Evict[] = []
for (const index of indexes) {
const name = storage.key(index)
if (!name) continue
if (!name.startsWith(LOCAL_PREFIX)) continue
if (name === keep) continue
const stored = storage.getItem(name)
items.push({ key: name, size: stored?.length ?? 0 })
}
items.sort((a, b) => b.size - a.size)
for (const item of items) {
storage.removeItem(item.key)
try {
storage.setItem(keep, value)
return true
} catch (error) {
if (!quota(error)) throw error
}
}
return false
}
function write(storage: Storage, key: string, value: string) {
try {
storage.setItem(key, value)
return
return true
} catch (error) {
if (!quota(error)) throw error
}
@@ -42,9 +86,12 @@ function write(storage: Storage, key: string, value: string) {
try {
storage.removeItem(key)
storage.setItem(key, value)
return true
} catch (error) {
if (!quota(error)) throw error
}
return evict(storage, key, value)
}
function snapshot(value: unknown) {
@@ -99,17 +146,64 @@ function localStorageWithPrefix(prefix: string): SyncStorage {
const base = `${prefix}:`
const item = (key: string) => base + key
return {
getItem: (key) => localStorage.getItem(item(key)),
setItem: (key, value) => write(localStorage, item(key), value),
removeItem: (key) => localStorage.removeItem(item(key)),
getItem: (key) => {
const name = item(key)
const cached = cache.get(name)
if (fallback.disabled && cached !== undefined) return cached
const stored = localStorage.getItem(name)
if (stored === null) return cached ?? null
cache.set(name, stored)
return stored
},
setItem: (key, value) => {
const name = item(key)
cache.set(name, value)
if (fallback.disabled) return
try {
if (write(localStorage, name, value)) return
} catch {
fallback.disabled = true
return
}
fallback.disabled = true
},
removeItem: (key) => {
const name = item(key)
cache.delete(name)
if (fallback.disabled) return
localStorage.removeItem(name)
},
}
}
function localStorageDirect(): SyncStorage {
return {
getItem: (key) => localStorage.getItem(key),
setItem: (key, value) => write(localStorage, key, value),
removeItem: (key) => localStorage.removeItem(key),
getItem: (key) => {
const cached = cache.get(key)
if (fallback.disabled && cached !== undefined) return cached
const stored = localStorage.getItem(key)
if (stored === null) return cached ?? null
cache.set(key, stored)
return stored
},
setItem: (key, value) => {
cache.set(key, value)
if (fallback.disabled) return
try {
if (write(localStorage, key, value)) return
} catch {
fallback.disabled = true
return
}
fallback.disabled = true
},
removeItem: (key) => {
cache.delete(key)
if (fallback.disabled) return
localStorage.removeItem(key)
},
}
}
+44
View File
@@ -0,0 +1,44 @@
import nope01 from "@opencode-ai/ui/audio/nope-01.aac"
import nope02 from "@opencode-ai/ui/audio/nope-02.aac"
import nope03 from "@opencode-ai/ui/audio/nope-03.aac"
import nope04 from "@opencode-ai/ui/audio/nope-04.aac"
import nope05 from "@opencode-ai/ui/audio/nope-05.aac"
import staplebops01 from "@opencode-ai/ui/audio/staplebops-01.aac"
import staplebops02 from "@opencode-ai/ui/audio/staplebops-02.aac"
import staplebops03 from "@opencode-ai/ui/audio/staplebops-03.aac"
import staplebops04 from "@opencode-ai/ui/audio/staplebops-04.aac"
import staplebops05 from "@opencode-ai/ui/audio/staplebops-05.aac"
import staplebops06 from "@opencode-ai/ui/audio/staplebops-06.aac"
import staplebops07 from "@opencode-ai/ui/audio/staplebops-07.aac"
export const SOUND_OPTIONS = [
{ id: "staplebops-01", label: "Boopy", src: staplebops01 },
{ id: "staplebops-02", label: "Beepy", src: staplebops02 },
{ id: "staplebops-03", label: "Staplebops 03", src: staplebops03 },
{ id: "staplebops-04", label: "Staplebops 04", src: staplebops04 },
{ id: "staplebops-05", label: "Staplebops 05", src: staplebops05 },
{ id: "staplebops-06", label: "Staplebops 06", src: staplebops06 },
{ id: "staplebops-07", label: "Staplebops 07", src: staplebops07 },
{ id: "nope-01", label: "Nope 01", src: nope01 },
{ id: "nope-02", label: "Nope 02", src: nope02 },
{ id: "nope-03", label: "Oopsie", src: nope03 },
{ id: "nope-04", label: "Nope 04", src: nope04 },
{ id: "nope-05", label: "Nope 05", src: nope05 },
] as const
export type SoundOption = (typeof SOUND_OPTIONS)[number]
export type SoundID = SoundOption["id"]
const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record<SoundID, string>
export function soundSrc(id: string | undefined) {
if (!id) return
if (!(id in soundById)) return
return soundById[id as SoundID]
}
export function playSound(src: string | undefined) {
if (typeof Audio === "undefined") return
if (!src) return
void new Audio(src).play().catch(() => undefined)
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.1.25",
"version": "1.1.28",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.1.25",
"version": "1.1.28",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.1.25",
"version": "1.1.28",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -35,7 +35,7 @@ export const subjects = createSubjects({
const MY_THEME: Theme = {
...THEME_OPENAUTH,
logo: "https://opencode.ai/favicon.svg",
logo: "https://opencode.ai/favicon-v2.svg",
}
export default {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.1.25",
"version": "1.1.28",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+4 -4
View File
@@ -4,10 +4,10 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OpenCode</title>
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" href="/favicon-96x96-v2.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon-v2.svg" />
<link rel="shortcut icon" href="/favicon-v2.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-v2.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#F8F7F7" />
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.1.25",
"version": "1.1.28",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -19,7 +19,7 @@
"url": "/",
"decorations": true,
"dragDropEnabled": false,
"zoomHotkeysEnabled": true,
"zoomHotkeysEnabled": false,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
"trafficLightPosition": { "x": 12.0, "y": 18.0 }
+17 -6
View File
@@ -1,4 +1,5 @@
// @refresh reload
import "./webview-zoom"
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface, PlatformProvider, Platform } from "@opencode-ai/app"
import { open, save } from "@tauri-apps/plugin-dialog"
@@ -94,6 +95,21 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
const apiCache = new Map<string, AsyncStorage & { flush: () => Promise<void> }>()
const memoryCache = new Map<string, StoreLike>()
const flushAll = async () => {
const apis = Array.from(apiCache.values())
await Promise.all(apis.map((api) => api.flush().catch(() => undefined)))
}
if ("addEventListener" in globalThis) {
const handleVisibility = () => {
if (document.visibilityState !== "hidden") return
void flushAll()
}
window.addEventListener("pagehide", () => void flushAll())
document.addEventListener("visibilitychange", handleVisibility)
}
const createMemoryStore = () => {
const data = new Map<string, string>()
const store: StoreLike = {
@@ -253,7 +269,7 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
.then(() => {
const notification = new Notification(title, {
body: description ?? "",
icon: "https://opencode.ai/favicon-96x96.png",
icon: "https://opencode.ai/favicon-96x96-v2.png",
})
notification.onclick = () => {
const win = getCurrentWindow()
@@ -303,11 +319,6 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
createMenu()
// Stops mousewheel events from reaching Tauri's pinch-to-zoom handler
root?.addEventListener("mousewheel", (e) => {
e.stopPropagation()
})
render(() => {
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
const platform = createPlatform(() => serverPassword())
+13
View File
@@ -1,5 +1,7 @@
import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/menu"
import { type as ostype } from "@tauri-apps/plugin-os"
import { invoke } from "@tauri-apps/api/core"
import { relaunch } from "@tauri-apps/plugin-process"
import { runUpdater, UPDATER_ENABLED } from "./updater"
import { installCli } from "./cli"
@@ -24,6 +26,17 @@ export async function createMenu() {
action: () => installCli(),
text: "Install CLI...",
}),
await MenuItem.new({
action: async () => window.location.reload(),
text: "Reload Webview",
}),
await MenuItem.new({
action: async () => {
await invoke("kill_sidecar").catch(() => undefined)
await relaunch().catch(() => undefined)
},
text: "Restart",
}),
await PredefinedMenuItem.new({
item: "Separator",
}),
+31
View File
@@ -0,0 +1,31 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
import { invoke } from "@tauri-apps/api/core"
import { type as ostype } from "@tauri-apps/plugin-os"
const OS_NAME = ostype()
let zoomLevel = 1
const MAX_ZOOM_LEVEL = 10
const MIN_ZOOM_LEVEL = 0.2
window.addEventListener("keydown", (event) => {
if (OS_NAME === "macos" ? event.metaKey : event.ctrlKey) {
if (event.key === "-") {
zoomLevel -= 0.2
} else if (event.key === "=" || event.key === "+") {
zoomLevel += 0.2
} else if (event.key === "0") {
zoomLevel = 1
} else {
return
}
zoomLevel = Math.min(Math.max(zoomLevel, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
invoke("plugin:webview|set_webview_zoom", {
value: zoomLevel,
})
}
})
+1 -1
View File
@@ -7,7 +7,7 @@
"light": "#07C983",
"dark": "#15803D"
},
"favicon": "/favicon.svg",
"favicon": "/favicon-v2.svg",
"navigation": {
"tabs": [
{
+19
View File
@@ -0,0 +1,19 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.06145 23.1079C5.26816 22.3769 -3.39077 20.6274 1.4173 5.06384C9.6344 6.09939 16.9728 14.0644 9.06145 23.1079Z" fill="url(#paint0_linear_17557_2021)"/>
<path d="M8.91928 23.0939C5.27642 21.2223 0.78371 4.20891 17.0071 0C20.7569 7.19341 19.6212 16.5452 8.91928 23.0939Z" fill="url(#paint1_linear_17557_2021)"/>
<path d="M8.91388 23.0788C8.73534 19.8817 10.1585 9.08525 23.5699 13.1107C23.1812 20.1229 18.984 26.4182 8.91388 23.0788Z" fill="url(#paint2_linear_17557_2021)"/>
<defs>
<linearGradient id="paint0_linear_17557_2021" x1="3.77557" y1="5.91571" x2="5.23185" y2="21.5589" gradientUnits="userSpaceOnUse">
<stop stop-color="#18E299"/>
<stop offset="1" stop-color="#15803D"/>
</linearGradient>
<linearGradient id="paint1_linear_17557_2021" x1="12.1711" y1="-0.718425" x2="10.1897" y2="22.9832" gradientUnits="userSpaceOnUse">
<stop stop-color="#16A34A"/>
<stop offset="1" stop-color="#4ADE80"/>
</linearGradient>
<linearGradient id="paint2_linear_17557_2021" x1="23.1327" y1="15.353" x2="9.33841" y2="18.5196" gradientUnits="userSpaceOnUse">
<stop stop-color="#4ADE80"/>
<stop offset="1" stop-color="#0D9373"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.1.25",
"version": "1.1.28",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.1.25"
version = "1.1.28"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.28/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.28/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.28/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.28/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.25/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.28/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.1.25",
"version": "1.1.28",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.1.25",
"version": "1.1.28",
"name": "opencode",
"type": "module",
"license": "MIT",
+11 -2
View File
@@ -12,6 +12,7 @@ import {
type PermissionOption,
type PlanEntry,
type PromptRequest,
type Role,
type SetSessionModelRequest,
type SetSessionModeRequest,
type SetSessionModeResponse,
@@ -687,7 +688,8 @@ export namespace ACP {
break
}
} else if (part.type === "text") {
if (part.text && !part.ignored) {
if (part.text) {
const audience: Role[] | undefined = part.synthetic ? ["assistant"] : part.ignored ? ["user"] : undefined
await this.connection
.sessionUpdate({
sessionId,
@@ -696,6 +698,7 @@ export namespace ACP {
content: {
type: "text",
text: part.text,
...(audience && { annotations: { audience } }),
},
},
})
@@ -968,14 +971,20 @@ export namespace ACP {
const agent = session.modeId ?? (await AgentModule.defaultAgent())
const parts: Array<
{ type: "text"; text: string } | { type: "file"; url: string; filename: string; mime: string }
| { type: "text"; text: string; synthetic?: boolean; ignored?: boolean }
| { type: "file"; url: string; filename: string; mime: string }
> = []
for (const part of params.prompt) {
switch (part.type) {
case "text":
const audience = part.annotations?.audience
const forAssistant = audience?.length === 1 && audience[0] === "assistant"
const forUser = audience?.length === 1 && audience[0] === "user"
parts.push({
type: "text" as const,
text: part.text,
...(forAssistant && { synthetic: true }),
...(forUser && { ignored: true }),
})
break
case "image": {
+1 -1
View File
@@ -134,7 +134,7 @@ const AgentCreateCommand = cmd({
selectedTools = cliTools ? cliTools.split(",").map((t) => t.trim()) : AVAILABLE_TOOLS
} else {
const result = await prompts.multiselect({
message: "Select tools to enable",
message: "Select tools to enable (Space to toggle)",
options: AVAILABLE_TOOLS.map((tool) => ({
label: tool,
value: tool,
@@ -1,94 +0,0 @@
import { createMemo, For } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { TIPS } from "./tips"
import { EmptyBorder } from "./border"
const tip = TIPS[Math.floor(Math.random() * TIPS.length)]
type TipPart = { text: string; highlight: boolean }
function parseTip(tip: string): TipPart[] {
const parts: TipPart[] = []
const regex = /\{highlight\}(.*?)\{\/highlight\}/g
let lastIndex = 0
let match
while ((match = regex.exec(tip)) !== null) {
if (match.index > lastIndex) {
parts.push({ text: tip.slice(lastIndex, match.index), highlight: false })
}
parts.push({ text: match[1], highlight: true })
lastIndex = regex.lastIndex
}
if (lastIndex < tip.length) {
parts.push({ text: tip.slice(lastIndex), highlight: false })
}
return parts
}
const tipParts = parseTip(tip)
const BOX_WIDTH = 42
const TITLE = " 🅘 Did you know? "
export function DidYouKnow() {
const { theme } = useTheme()
const dashes = createMemo(() => {
// ╭─ + title + ─...─ + ╮ = BOX_WIDTH
// 1 + 1 + title.length + dashes + 1 = BOX_WIDTH
return Math.max(0, BOX_WIDTH - 2 - TITLE.length - 1)
})
return (
<box position="absolute" bottom={3} right={2} width={BOX_WIDTH}>
<text>
<span style={{ fg: theme.border }}></span>
<span style={{ fg: theme.text }}>{TITLE}</span>
<span style={{ fg: theme.border }}>{"─".repeat(dashes())}</span>
</text>
<box
border={["left", "right", "bottom"]}
borderColor={theme.border}
customBorderChars={{
...EmptyBorder,
bottomLeft: "╰",
bottomRight: "╯",
horizontal: "─",
vertical: "│",
}}
>
<box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
<text>
<For each={tipParts}>
{(part) => <span style={{ fg: part.highlight ? theme.text : theme.textMuted }}>{part.text}</span>}
</For>
</text>
</box>
</box>
<box flexDirection="row" justifyContent="flex-end">
<text>
<span style={{ fg: theme.text }}>ctrl+h</span>
<span style={{ fg: theme.textMuted }}> hide tips</span>
</text>
</box>
</box>
)
}
export function ShowTipsHint() {
const { theme } = useTheme()
return (
<box position="absolute" bottom={3} right={2}>
<box flexDirection="row" justifyContent="flex-end">
<text>
<span style={{ fg: theme.text }}>ctrl+h</span>
<span style={{ fg: theme.textMuted }}> show tips</span>
</text>
</box>
</box>
)
}
@@ -85,6 +85,7 @@ export function Autocomplete(props: {
index: 0,
selected: 0,
visible: false as AutocompleteRef["visible"],
input: "keyboard" as "keyboard" | "mouse",
})
const [positionTick, setPositionTick] = createSignal(0)
@@ -128,6 +129,14 @@ export function Autocomplete(props: {
return props.input().getTextRange(store.index + 1, props.input().cursorOffset)
})
// When the filter changes due to how TUI works, the mousemove might still be triggered
// via a synthetic event as the layout moves underneath the cursor. This is a workaround to make sure the input mode remains keyboard so
// that the mouseover event doesn't trigger when filtering.
createEffect(() => {
filter()
setStore("input", "keyboard")
})
function insertPart(text: string, part: PromptInfo["parts"][number]) {
const input = props.input()
const currentCursorOffset = input.cursorOffset
@@ -525,11 +534,13 @@ export function Autocomplete(props: {
const isNavDown = name === "down" || (ctrlOnly && name === "n")
if (isNavUp) {
setStore("input", "keyboard")
move(-1)
e.preventDefault()
return
}
if (isNavDown) {
setStore("input", "keyboard")
move(1)
e.preventDefault()
return
@@ -612,7 +623,17 @@ export function Autocomplete(props: {
paddingRight={1}
backgroundColor={index === store.selected ? theme.primary : undefined}
flexDirection="row"
onMouseOver={() => moveTo(index)}
onMouseMove={() => {
setStore("input", "mouse")
}}
onMouseOver={() => {
if (store.input !== "mouse") return
moveTo(index)
}}
onMouseDown={() => {
setStore("input", "mouse")
moveTo(index)
}}
onMouseUp={() => select()}
>
<text fg={index === store.selected ? selectedForeground(theme) : theme.text} flexShrink={0}>
@@ -546,16 +546,22 @@ export function Prompt(props: PromptProps) {
} else if (
inputText.startsWith("/") &&
iife(() => {
const command = inputText.split(" ")[0].slice(1)
console.log(command)
const firstLine = inputText.split("\n")[0]
const command = firstLine.split(" ")[0].slice(1)
return sync.data.command.some((x) => x.name === command)
})
) {
let [command, ...args] = inputText.split(" ")
// Parse command from first line, preserve multi-line content in arguments
const firstLineEnd = inputText.indexOf("\n")
const firstLine = firstLineEnd === -1 ? inputText : inputText.slice(0, firstLineEnd)
const [command, ...firstLineArgs] = firstLine.split(" ")
const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1)
const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "")
sdk.client.session.command({
sessionID,
command: command.slice(1),
arguments: args.join(" "),
arguments: args,
agent: local.agent.current().name,
model: `${selectedModel.providerID}/${selectedModel.modelID}`,
messageID,
@@ -1,103 +0,0 @@
export const TIPS = [
"Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files to your prompt.",
"Start a message with {highlight}!{/highlight} to run shell commands directly (e.g., {highlight}!ls -la{/highlight}).",
"Press {highlight}Tab{/highlight} to cycle between Build (full access) and Plan (read-only) agents.",
"Use {highlight}/undo{/highlight} to revert the last message and any file changes made by OpenCode.",
"Use {highlight}/redo{/highlight} to restore previously undone messages and file changes.",
"Run {highlight}/share{/highlight} to create a public link to your conversation at opencode.ai.",
"Drag and drop images into the terminal to add them as context for your prompts.",
"Press {highlight}Ctrl+V{/highlight} to paste images from your clipboard directly into the prompt.",
"Press {highlight}Ctrl+X E{/highlight} or {highlight}/editor{/highlight} to compose messages in your external editor.",
"Run {highlight}/init{/highlight} to auto-generate project rules based on your codebase structure.",
"Run {highlight}/models{/highlight} or {highlight}Ctrl+X M{/highlight} to see and switch between available AI models.",
"Use {highlight}/theme{/highlight} or {highlight}Ctrl+X T{/highlight} to preview and switch between 50+ built-in themes.",
"Press {highlight}Ctrl+X N{/highlight} or {highlight}/new{/highlight} to start a fresh conversation session.",
"Use {highlight}/sessions{/highlight} or {highlight}Ctrl+X L{/highlight} to list and continue previous conversations.",
"Run {highlight}/compact{/highlight} to summarize long sessions when approaching context limits.",
"Press {highlight}Ctrl+X X{/highlight} or {highlight}/export{/highlight} to save the conversation as Markdown.",
"Press {highlight}Ctrl+X Y{/highlight} to copy the assistant's last message to clipboard.",
"Press {highlight}Ctrl+P{/highlight} to see all available actions and commands.",
"Run {highlight}/connect{/highlight} to add API keys for 75+ supported LLM providers.",
"The default leader key is {highlight}Ctrl+X{/highlight}; combine with other keys for quick actions.",
"Press {highlight}F2{/highlight} to quickly switch between recently used models.",
"Press {highlight}Ctrl+X B{/highlight} to show/hide the sidebar panel.",
"Use {highlight}PageUp{/highlight}/{highlight}PageDown{/highlight} to navigate through conversation history.",
"Press {highlight}Ctrl+G{/highlight} or {highlight}Home{/highlight} to jump to the beginning of the conversation.",
"Press {highlight}Ctrl+Alt+G{/highlight} or {highlight}End{/highlight} to jump to the most recent message.",
"Press {highlight}Shift+Enter{/highlight} or {highlight}Ctrl+J{/highlight} to add newlines in your prompt.",
"Press {highlight}Ctrl+C{/highlight} when typing to clear the input field.",
"Press {highlight}Escape{/highlight} to stop the AI mid-response.",
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes.",
"Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents.",
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions.",
"Create {highlight}opencode.json{/highlight} in project root for project-specific settings.",
"Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config.",
"Add {highlight}$schema{/highlight} to your config for autocomplete in your editor.",
"Configure {highlight}model{/highlight} in config to set your default model.",
"Override any keybind in config via the {highlight}keybinds{/highlight} section.",
"Set any keybind to {highlight}none{/highlight} to disable it completely.",
"Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section.",
"OpenCode auto-handles OAuth for remote MCP servers requiring auth.",
"Add {highlight}.md{/highlight} files to {highlight}.opencode/command/{/highlight} to define reusable custom prompts.",
"Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input.",
"Use backticks in commands to inject shell output (e.g., {highlight}`git status`{/highlight}).",
"Add {highlight}.md{/highlight} files to {highlight}.opencode/agent/{/highlight} for specialized AI personas.",
"Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}bash{/highlight}, and {highlight}webfetch{/highlight} tools.",
'Use patterns like {highlight}"git *": "allow"{/highlight} for granular bash permissions.',
'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands.',
'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing.',
"OpenCode auto-formats files using prettier, gofmt, ruff, and more.",
'Set {highlight}"formatter": false{/highlight} in config to disable all auto-formatting.',
"Define custom formatter commands with file extensions in config.",
"OpenCode uses LSP servers for intelligent code analysis.",
"Create {highlight}.ts{/highlight} files in {highlight}.opencode/tool/{/highlight} to define new LLM tools.",
"Tool definitions can invoke scripts written in Python, Go, etc.",
"Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugin/{/highlight} for event hooks.",
"Use plugins to send OS notifications when sessions complete.",
"Create a plugin to prevent OpenCode from reading sensitive files.",
"Use {highlight}opencode run{/highlight} for non-interactive scripting.",
"Use {highlight}opencode run --continue{/highlight} to resume the last session.",
"Use {highlight}opencode run -f file.ts{/highlight} to attach files via CLI.",
"Use {highlight}--format json{/highlight} for machine-readable output in scripts.",
"Run {highlight}opencode serve{/highlight} for headless API access to OpenCode.",
"Use {highlight}opencode run --attach{/highlight} to connect to a running server for faster runs.",
"Run {highlight}opencode upgrade{/highlight} to update to the latest version.",
"Run {highlight}opencode auth list{/highlight} to see all configured providers.",
"Run {highlight}opencode agent create{/highlight} for guided agent creation.",
"Use {highlight}/opencode{/highlight} in GitHub issues/PRs to trigger AI actions.",
"Run {highlight}opencode github install{/highlight} to set up the GitHub workflow.",
"Comment {highlight}/opencode fix this{/highlight} on issues to auto-create PRs.",
"Comment {highlight}/oc{/highlight} on PR code lines for targeted code reviews.",
'Use {highlight}"theme": "system"{/highlight} to match your terminal\'s colors.',
"Create JSON theme files in {highlight}.opencode/themes/{/highlight} directory.",
"Themes support dark/light variants for both modes.",
"Reference ANSI colors 0-255 in custom themes.",
"Use {highlight}{env:VAR_NAME}{/highlight} syntax to reference environment variables in config.",
"Use {highlight}{file:path}{/highlight} to include file contents in config values.",
"Use {highlight}instructions{/highlight} in config to load additional rules files.",
"Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative).",
"Configure {highlight}maxSteps{/highlight} to limit agentic iterations per request.",
'Set {highlight}"tools": {"bash": false}{/highlight} to disable specific tools.',
'Use {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server.',
"Override global tool settings per agent configuration.",
'Set {highlight}"share": "auto"{/highlight} to automatically share all sessions.',
'Set {highlight}"share": "disabled"{/highlight} to prevent any session sharing.',
"Run {highlight}/unshare{/highlight} to remove a session from public access.",
"Permission {highlight}doom_loop{/highlight} prevents infinite tool call loops.",
"Permission {highlight}external_directory{/highlight} protects files outside project.",
"Run {highlight}opencode debug config{/highlight} to troubleshoot configuration.",
"Use {highlight}--print-logs{/highlight} flag to see detailed logs in stderr.",
"Press {highlight}Ctrl+X G{/highlight} or {highlight}/timeline{/highlight} to jump to specific messages.",
"Press {highlight}Ctrl+X H{/highlight} to toggle code block visibility in messages.",
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info.",
"Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling.",
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight}).",
"Run {highlight}docker run -it --rm ghcr.io/sst/opencode{/highlight} for containerized use.",
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models.",
"Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing.",
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs.",
"Run {highlight}/help{/highlight} or {highlight}Ctrl+X H{/highlight} to show the help dialog.",
"Use {highlight}/details{/highlight} to toggle tool execution details visibility.",
"Use {highlight}/rename{/highlight} to rename the current session.",
"Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell.",
]
@@ -106,7 +106,7 @@ const TIPS = [
"Use plugins to send OS notifications when sessions complete",
"Create a plugin to prevent OpenCode from reading sensitive files",
"Use {highlight}opencode run{/highlight} for non-interactive scripting",
"Use {highlight}opencode run --continue{/highlight} to resume the last session",
"Use {highlight}opencode --continue{/highlight} to resume the last session",
"Use {highlight}opencode run -f file.ts{/highlight} to attach files via CLI",
"Use {highlight}--format json{/highlight} for machine-readable output in scripts",
"Run {highlight}opencode serve{/highlight} for headless API access to OpenCode",
@@ -241,9 +241,27 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
event.properties.info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
if (draft.length > 100) draft.shift()
}),
)
const updated = store.message[event.properties.info.sessionID]
if (updated.length > 100) {
const oldest = updated[0]
batch(() => {
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.shift()
}),
)
setStore(
"part",
produce((draft) => {
delete draft[oldest.id]
}),
)
})
}
break
}
case "message.removed": {
@@ -1,10 +1,9 @@
import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createMemo, Match, onMount, Show, Switch } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { useKeyboard } from "@opentui/solid"
import { useKeybind } from "@tui/context/keybind"
import { Logo } from "../component/logo"
import { Tips } from "../component/tips"
import { DidYouKnow, ShowTipsHint } from "../component/did-you-know"
import { Locale } from "@/util/locale"
import { useSync } from "../context/sync"
import { Toast } from "../ui/toast"
@@ -43,22 +42,6 @@ export function Home() {
return !tipsHidden()
})
function hideTips() {
kv.set("tips_hidden", true)
}
function enableTips() {
kv.set("tips_hidden", false)
}
function toggleTips() {
if (showTips()) {
hideTips()
return
}
enableTips()
}
command.register(() => [
{
title: tipsHidden() ? "Show tips" : "Hide tips",
@@ -66,20 +49,12 @@ export function Home() {
keybind: "tips_toggle",
category: "System",
onSelect: (dialog) => {
toggleTips()
kv.set("tips_hidden", !tipsHidden())
dialog.clear()
},
},
])
useKeyboard((evt) => {
// Don't handle tips keybind for first-time users
if (isFirstTimeUser()) return
if (evt.name !== "h" || !evt.ctrl || evt.meta || evt.shift) return
toggleTips()
evt.preventDefault()
})
const Hint = (
<Show when={connectedMcpCount() > 0}>
<box flexShrink={0} flexDirection="row" gap={1}>
@@ -114,6 +89,8 @@ export function Home() {
})
const directory = useDirectory()
const keybind = useKeybind()
return (
<>
<box flexGrow={1} justifyContent="center" alignItems="center" paddingLeft={2} paddingRight={2} gap={1}>
@@ -135,11 +112,6 @@ export function Home() {
</box>
<Toast />
</box>
<Show when={!isFirstTimeUser()}>
<Show when={showTips()} fallback={<ShowTipsHint />}>
<DidYouKnow />
</Show>
</Show>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}>
<text fg={theme.textMuted}>{directory()}</text>
<box gap={1} flexDirection="row" flexShrink={0}>
@@ -73,6 +73,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
<box
backgroundColor={theme.backgroundPanel}
width={42}
height="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
@@ -52,6 +52,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const [store, setStore] = createStore({
selected: 0,
filter: "",
input: "keyboard" as "keyboard" | "mouse",
})
createEffect(
@@ -83,6 +84,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
return result
})
// When the filter changes due to how TUI works, the mousemove might still be triggered
// via a synthetic event as the layout moves underneath the cursor. This is a workaround to make sure the input mode remains keyboard
// that the mouseover event doesn't trigger when filtering.
createEffect(() => {
filtered()
setStore("input", "keyboard")
})
const grouped = createMemo(() => {
const result = pipe(
filtered(),
@@ -157,12 +166,15 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const keybind = useKeybind()
useKeyboard((evt) => {
setStore("input", "keyboard")
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1)
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
if (evt.name === "pageup") move(-10)
if (evt.name === "pagedown") move(10)
if (evt.name === "home") moveTo(0)
if (evt.name === "end") moveTo(flat().length - 1)
if (evt.name === "return") {
const option = selected()
if (option) {
@@ -259,11 +271,20 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<box
id={JSON.stringify(option.value)}
flexDirection="row"
onMouseMove={() => {
setStore("input", "mouse")
}}
onMouseUp={() => {
option.onSelect?.(dialog)
props.onSelect?.(option)
}}
onMouseOver={() => {
if (store.input !== "mouse") return
const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
if (index === -1) return
moveTo(index)
}}
onMouseDown={() => {
const index = flat().findIndex((x) => isDeepEqual(x.value, option.value))
if (index === -1) return
moveTo(index)
@@ -337,6 +358,7 @@ function Option(props: {
fg={props.active ? fg : props.current ? theme.primary : theme.text}
attributes={props.active ? TextAttributes.BOLD : undefined}
overflow="hidden"
wrapMode="none"
paddingLeft={3}
>
{Locale.truncate(props.title, 61)}
+107 -1
View File
@@ -12,7 +12,13 @@ import { lazy } from "../util/lazy"
import { NamedError } from "@opencode-ai/util/error"
import { Flag } from "../flag/flag"
import { Auth } from "../auth"
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
import {
type ParseError as JsoncParseError,
applyEdits,
modify,
parse as parseJsonc,
printParseErrorCode,
} from "jsonc-parser"
import { Instance } from "../project/instance"
import { LSPServer } from "../lsp/server"
import { BunProc } from "@/bun"
@@ -20,6 +26,8 @@ import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown"
import { existsSync } from "fs"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
export namespace Config {
const log = Log.create({ service: "config" })
@@ -1242,6 +1250,10 @@ export namespace Config {
return state().then((x) => x.config)
}
export async function getGlobal() {
return global()
}
export async function update(config: Info) {
const filepath = path.join(Instance.directory, "config.json")
const existing = await loadFile(filepath)
@@ -1249,6 +1261,100 @@ export namespace Config {
await Instance.dispose()
}
function globalConfigFile() {
const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) =>
path.join(Global.Path.config, file),
)
for (const file of candidates) {
if (existsSync(file)) return file
}
return candidates[0]
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
}
function patchJsonc(input: string, patch: unknown, path: string[] = []): string {
if (!isRecord(patch)) {
const edits = modify(input, path, patch, {
formattingOptions: {
insertSpaces: true,
tabSize: 2,
},
})
return applyEdits(input, edits)
}
return Object.entries(patch).reduce((result, [key, value]) => {
if (value === undefined) return result
return patchJsonc(result, value, [...path, key])
}, input)
}
function parseConfig(text: string, filepath: string): Info {
const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true })
if (errors.length) {
const lines = text.split("\n")
const errorDetails = errors
.map((e) => {
const beforeOffset = text.substring(0, e.offset).split("\n")
const line = beforeOffset.length
const column = beforeOffset[beforeOffset.length - 1].length + 1
const problemLine = lines[line - 1]
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
if (!problemLine) return error
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
})
.join("\n")
throw new JsonError({
path: filepath,
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
})
}
const parsed = Info.safeParse(data)
if (parsed.success) return parsed.data
throw new InvalidError({
path: filepath,
issues: parsed.error.issues,
})
}
export async function updateGlobal(config: Info) {
const filepath = globalConfigFile()
const before = await Bun.file(filepath)
.text()
.catch((err) => {
if (err.code === "ENOENT") return "{}"
throw new JsonError({ path: filepath }, { cause: err })
})
if (!filepath.endsWith(".jsonc")) {
const existing = parseConfig(before, filepath)
await Bun.write(filepath, JSON.stringify(mergeDeep(existing, config), null, 2))
} else {
const next = patchJsonc(before, config)
parseConfig(next, filepath)
await Bun.write(filepath, next)
}
global.reset()
await Instance.disposeAll()
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Event.Disposed.type,
properties: {},
},
})
}
export async function directories() {
return state().then((x) => x.directories)
}
+24 -26
View File
@@ -162,34 +162,32 @@ export namespace Ripgrep {
})
}
if (config.extension === "zip") {
if (config.extension === "zip") {
const zipFileReader = new ZipReader(new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()])))
const entries = await zipFileReader.getEntries()
let rgEntry: any
for (const entry of entries) {
if (entry.filename.endsWith("rg.exe")) {
rgEntry = entry
break
}
const zipFileReader = new ZipReader(new BlobReader(new Blob([await Bun.file(archivePath).arrayBuffer()])))
const entries = await zipFileReader.getEntries()
let rgEntry: any
for (const entry of entries) {
if (entry.filename.endsWith("rg.exe")) {
rgEntry = entry
break
}
if (!rgEntry) {
throw new ExtractionFailedError({
filepath: archivePath,
stderr: "rg.exe not found in zip archive",
})
}
const rgBlob = await rgEntry.getData(new BlobWriter())
if (!rgBlob) {
throw new ExtractionFailedError({
filepath: archivePath,
stderr: "Failed to extract rg.exe from zip archive",
})
}
await Bun.write(filepath, await rgBlob.arrayBuffer())
await zipFileReader.close()
}
if (!rgEntry) {
throw new ExtractionFailedError({
filepath: archivePath,
stderr: "rg.exe not found in zip archive",
})
}
const rgBlob = await rgEntry.getData(new BlobWriter())
if (!rgBlob) {
throw new ExtractionFailedError({
filepath: archivePath,
stderr: "Failed to extract rg.exe from zip archive",
})
}
await Bun.write(filepath, await rgBlob.arrayBuffer())
await zipFileReader.close()
}
await fs.unlink(archivePath)
if (!platformKey.endsWith("-win32")) await fs.chmod(filepath, 0o755)
+16 -7
View File
@@ -32,11 +32,16 @@ export namespace FileWatcher {
),
}
const watcher = lazy(() => {
const binding = require(
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
)
return createWrapper(binding) as typeof import("@parcel/watcher")
const watcher = lazy((): typeof import("@parcel/watcher") | undefined => {
try {
const binding = require(
`@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${OPENCODE_LIBC || "glibc"}` : ""}`,
)
return createWrapper(binding) as typeof import("@parcel/watcher")
} catch (error) {
log.error("failed to load watcher binding", { error })
return
}
})
const state = Instance.state(
@@ -54,6 +59,10 @@ export namespace FileWatcher {
return {}
}
log.info("watcher backend", { platform: process.platform, backend })
const w = watcher()
if (!w) return {}
const subscribe: ParcelWatcher.SubscribeCallback = (err, evts) => {
if (err) return
for (const evt of evts) {
@@ -67,7 +76,7 @@ export namespace FileWatcher {
const cfgIgnores = cfg.watcher?.ignore ?? []
if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
const pending = watcher().subscribe(Instance.directory, subscribe, {
const pending = w.subscribe(Instance.directory, subscribe, {
ignore: [...FileIgnore.PATTERNS, ...cfgIgnores],
backend,
})
@@ -89,7 +98,7 @@ export namespace FileWatcher {
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
const gitDirContents = await readdir(vcsDir).catch(() => [])
const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD")
const pending = watcher().subscribe(vcsDir, subscribe, {
const pending = w.subscribe(vcsDir, subscribe, {
ignore: ignoreList,
backend,
})
+7 -2
View File
@@ -795,6 +795,11 @@ export namespace MCP {
// The SDK has already added the state parameter to the authorization URL
// We just need to open the browser
log.info("opening browser for oauth", { mcpName, url: authorizationUrl, state: oauthState })
// Register the callback BEFORE opening the browser to avoid race condition
// when the IdP has an active SSO session and redirects immediately
const callbackPromise = McpOAuthCallback.waitForCallback(oauthState)
try {
const subprocess = await open(authorizationUrl)
// The open package spawns a detached process and returns immediately.
@@ -822,8 +827,8 @@ export namespace MCP {
Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl })
}
// Wait for callback using the OAuth state parameter
const code = await McpOAuthCallback.waitForCallback(oauthState)
// Wait for callback using the already-registered promise
const code = await callbackPromise
// Validate and clear the state
const storedState = await McpAuth.getOAuthState(mcpName)
+4
View File
@@ -25,6 +25,7 @@ export namespace Project {
icon: z
.object({
url: z.string().optional(),
override: z.string().optional(),
color: z.string().optional(),
})
.optional(),
@@ -190,6 +191,7 @@ export namespace Project {
if (!existing.sandboxes) existing.sandboxes = []
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing)
const result: Info = {
...existing,
worktree,
@@ -213,6 +215,7 @@ export namespace Project {
export async function discover(input: Info) {
if (input.vcs !== "git") return
if (input.icon?.override) return
if (input.icon?.url) return
const glob = new Bun.Glob("**/{favicon}.{ico,png,svg,jpg,jpeg,webp}")
const matches = await Array.fromAsync(
@@ -293,6 +296,7 @@ export namespace Project {
...draft.icon,
}
if (input.icon.url !== undefined) draft.icon.url = input.icon.url
if (input.icon.override !== undefined) draft.icon.override = input.icon.override || undefined
if (input.icon.color !== undefined) draft.icon.color = input.icon.color
}
draft.time.updated = Date.now()
+3
View File
@@ -147,6 +147,9 @@ export namespace Pty {
log.info("session exited", { id, exitCode })
session.info.status = "exited"
Bus.publish(Event.Exited, { id, exitCode })
for (const ws of session.subscribers) {
ws.close()
}
state().delete(id)
})
Bus.publish(Event.Created, { info })
+7
View File
@@ -0,0 +1,7 @@
import { BusEvent } from "@/bus/bus-event"
import z from "zod"
export const Event = {
Connected: BusEvent.define("server.connected", z.object({})),
Disposed: BusEvent.define("global.disposed", z.object({})),
}
-5
View File
@@ -54,11 +54,6 @@ export namespace Server {
return _url ?? new URL("http://localhost:4096")
}
export const Event = {
Connected: BusEvent.define("server.connected", z.object({})),
Disposed: BusEvent.define("global.disposed", z.object({})),
}
const app = new Hono()
export const App: () => Hono = lazy(
() =>
+2 -2
View File
@@ -176,7 +176,7 @@ export namespace SessionProcessor {
...match,
state: {
status: "completed",
input: value.input,
input: value.input ?? match.state.input,
output: value.output.output,
metadata: value.output.metadata,
title: value.output.title,
@@ -200,7 +200,7 @@ export namespace SessionProcessor {
...match,
state: {
status: "error",
input: value.input,
input: value.input ?? match.state.input,
error: (value.error as any).toString(),
time: {
start: match.state.time.start,
+17 -2
View File
@@ -44,6 +44,7 @@ import { SessionStatus } from "./status"
import { LLM } from "./llm"
import { iife } from "@/util/iife"
import { Shell } from "@/shell/shell"
import { Truncate } from "@/tool/truncation"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -801,10 +802,17 @@ export namespace SessionPrompt {
}
}
const truncated = await Truncate.output(textParts.join("\n\n"), {}, input.agent)
const metadata = {
...(result.metadata ?? {}),
truncated: truncated.truncated,
...(truncated.truncated && { outputPath: truncated.outputPath }),
}
return {
title: "",
metadata: result.metadata ?? {},
output: textParts.join("\n\n"),
metadata,
output: truncated.content,
attachments,
content: result.content, // directly return content to preserve ordering when outputting to model
}
@@ -1619,8 +1627,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (position === last) return args.slice(argIndex).join(" ")
return args[argIndex]
})
const usesArgumentsPlaceholder = templateCommand.includes("$ARGUMENTS")
let template = withArgs.replaceAll("$ARGUMENTS", input.arguments)
// If command doesn't explicitly handle arguments (no $N or $ARGUMENTS placeholders)
// but user provided arguments, append them to the template
if (placeholders.length === 0 && !usesArgumentsPlaceholder && input.arguments.trim()) {
template = template + "\n\n" + input.arguments
}
const shell = ConfigMarkdown.shell(template)
if (shell.length > 0) {
const results = await Promise.all(
@@ -1,73 +0,0 @@
You are OpenCode, the best coding agent on the planet.
You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
## Editing constraints
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
- Only add comments if they are necessary to make a non-obvious block easier to understand.
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
## Tool usage
- Prefer specialized tools over shell for file operations:
- Use Read to view files, Edit to modify files, and Write only when needed.
- Use Glob to find files by name and Grep to search file contents.
- Use Bash for terminal operations (git, bun, builds, tests, running scripts).
- Run tool calls in parallel when neither call needs the others output; otherwise run sequentially.
## Git and workspace hygiene
- You may be in a dirty git worktree.
* NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.
* If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.
* If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.
* If the changes are in unrelated files, just ignore them and don't revert them.
- Do not amend commits unless explicitly requested.
- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.
## Frontend tasks
When doing frontend design tasks, avoid collapsing into bland, generic layouts.
Aim for interfaces that feel intentional and deliberate.
- Typography: Use expressive, purposeful fonts and avoid default stacks (Inter, Roboto, Arial, system).
- Color & Look: Choose a clear visual direction; define CSS variables; avoid purple-on-white defaults. No purple bias or dark mode bias.
- Motion: Use a few meaningful animations (page-load, staggered reveals) instead of generic micro-motions.
- Background: Don't rely on flat, single-color backgrounds; use gradients, shapes, or subtle patterns to build atmosphere.
- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.
- Ensure the page loads properly on both desktop and mobile.
Exception: If working within an existing website or design system, preserve the established patterns, structure, and visual language.
## Presenting your work and final message
You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.
- Default: be very concise; friendly coding teammate tone.
- Ask only when needed; suggest ideas; mirror the user's style.
- For substantial work, summarize clearly; follow finalanswer formatting.
- Skip heavy formatting for simple confirmations.
- Don't dump large files you've written; reference paths only.
- No "save/copy this file" - User is on the same machine.
- Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something.
- For code changes:
* Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with "summary", just jump right in.
* If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps.
* When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number.
- The user does not command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.
## Final answer structure and style guidelines
- Plain text; CLI handles styling. Use structure only when it helps scanability.
- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help.
- Bullets: use - ; merge related points; keep to one line when possible; 46 per list ordered by importance; keep phrasing consistent.
- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **.
- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible.
- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task.
- Tone: collaborative, concise, factual; present tense, active voice; selfcontained; no "above/below"; parallel wording.
- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers.
- Adaptation: code explanations → precise, structured with code refs; simple tasks → lead with outcome; big changes → logical walkthrough + rationale + next actions; casual one-offs → plain sentences, no headers/bullets.
- File References: When referencing files in your response follow the below rules:
* Use inline code to make file paths clickable.
* Each reference should have a stand alone path. Even if it's the same file.
* Accepted: absolute, workspacerelative, a/ or b/ diff prefixes, or bare filename/suffix.
* Optionally include line/column (1based): :line[:column] or #Lline[Ccolumn] (column defaults to 1).
* Do not use URIs like file://, vscode://, or https://.
* Do not provide range of lines
* Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5
@@ -5,6 +5,7 @@ You are an interactive CLI tool that helps users with software engineering tasks
## Editing constraints
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
- Only add comments if they are necessary to make a non-obvious block easier to understand.
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
## Tool usage
- Prefer specialized tools over shell for file operations:
@@ -39,7 +40,13 @@ Exception: If working within an existing website or design system, preserve the
You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.
- Default: be very concise; friendly coding teammate tone.
- Ask only when needed; suggest ideas; mirror the user's style.
- Default: do the work without asking questions. Treat short tasks as sufficient direction; infer missing details by reading the codebase and following existing conventions.
- Questions: only ask when you are truly blocked after checking relevant context AND you cannot safely pick a reasonable default. This usually means one of:
* The request is ambiguous in a way that materially changes the result and you cannot disambiguate by reading the repo.
* The action is destructive/irreversible, touches production, or changes billing/security posture.
* You need a secret/credential/value that cannot be inferred (API key, account id, etc.).
- If you must ask: do all non-blocked work first, then ask exactly one targeted question, include your recommended default, and state what would change based on the answer.
- Never ask permission questions like "Should I proceed?" or "Do you want me to run tests?"; proceed with the most reasonable option and mention what you did.
- For substantial work, summarize clearly; follow finalanswer formatting.
- Skip heavy formatting for simple confirmations.
- Don't dump large files you've written; reference paths only.
+2 -3
View File
@@ -13,8 +13,7 @@ import PROMPT_BEAST from "./prompt/beast.txt"
import PROMPT_GEMINI from "./prompt/gemini.txt"
import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
import PROMPT_CODEX from "./prompt/codex.txt"
import PROMPT_CODEX_INSTRUCTIONS from "./prompt/codex_header.txt"
import PROMPT_CODEX from "./prompt/codex_header.txt"
import type { Provider } from "@/provider/provider"
import { Flag } from "@/flag/flag"
@@ -25,7 +24,7 @@ export namespace SystemPrompt {
}
export function instructions() {
return PROMPT_CODEX_INSTRUCTIONS.trim()
return PROMPT_CODEX.trim()
}
export function provider(model: Provider.Model) {
@@ -2,7 +2,6 @@ import z from "zod"
import * as path from "path"
import * as fs from "fs/promises"
import { Tool } from "./tool"
import { FileTime } from "../file/time"
import { Bus } from "../bus"
import { FileWatcher } from "../file/watcher"
import { Instance } from "../project/instance"
@@ -96,8 +95,6 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`)
}
// Read file and update time tracking (like edit tool does)
await FileTime.assert(ctx.sessionID, filePath)
const oldContent = await fs.readFile(filePath, "utf-8")
let newContent = oldContent
@@ -202,12 +199,6 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
changedFiles.push(change.filePath)
break
}
// Update file time tracking
FileTime.read(ctx.sessionID, change.filePath)
if (change.movePath) {
FileTime.read(ctx.sessionID, change.movePath)
}
}
// Publish file change events
+4 -4
View File
@@ -33,8 +33,8 @@ export const BatchTool = Tool.define("batch", async () => {
const { Session } = await import("../session")
const { Identifier } = await import("../id/id")
const toolCalls = params.tool_calls.slice(0, 10)
const discardedCalls = params.tool_calls.slice(10)
const toolCalls = params.tool_calls.slice(0, 25)
const discardedCalls = params.tool_calls.slice(25)
const { ToolRegistry } = await import("./registry")
const availableTools = await ToolRegistry.tools({ modelID: "", providerID: "" })
@@ -139,14 +139,14 @@ export const BatchTool = Tool.define("batch", async () => {
state: {
status: "error",
input: call.parameters,
error: "Maximum of 10 tools allowed in batch",
error: "Maximum of 25 tools allowed in batch",
time: { start: now, end: now },
},
})
results.push({
success: false as const,
tool: call.tool,
error: new Error("Maximum of 10 tools allowed in batch"),
error: new Error("Maximum of 25 tools allowed in batch"),
})
}
+1 -1
View File
@@ -6,7 +6,7 @@ Payload Format (JSON array):
[{"tool": "read", "parameters": {"filePath": "src/index.ts", "limit": 350}},{"tool": "grep", "parameters": {"pattern": "Session\\.updatePart", "include": "src/**/*.ts"}},{"tool": "bash", "parameters": {"command": "git status", "description": "Shows working tree status"}}]
Notes:
- 110 tool calls per batch
- 125 tool calls per batch
- All calls start in parallel; ordering NOT guaranteed
- Partial failures do not stop other tool calls
- Do NOT use the batch tool within another batch tool.
@@ -3,7 +3,6 @@ import path from "path"
import * as fs from "fs/promises"
import { ApplyPatchTool } from "../../src/tool/apply_patch"
import { Instance } from "../../src/project/instance"
import { FileTime } from "../../src/file/time"
import { tmpdir } from "../fixture/fixture"
const baseCtx = {
@@ -71,8 +70,6 @@ describe("tool.apply_patch freeform", () => {
const deletePath = path.join(fixture.path, "delete.txt")
await fs.writeFile(modifyPath, "line1\nline2\n", "utf-8")
await fs.writeFile(deletePath, "obsolete\n", "utf-8")
FileTime.read(ctx.sessionID, modifyPath)
FileTime.read(ctx.sessionID, deletePath)
const patchText =
"*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Delete File: delete.txt\n*** Update File: modify.txt\n@@\n-line2\n+changed\n*** End Patch"
@@ -101,7 +98,6 @@ describe("tool.apply_patch freeform", () => {
fn: async () => {
const target = path.join(fixture.path, "multi.txt")
await fs.writeFile(target, "line1\nline2\nline3\nline4\n", "utf-8")
FileTime.read(ctx.sessionID, target)
const patchText =
"*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch"
@@ -122,7 +118,6 @@ describe("tool.apply_patch freeform", () => {
fn: async () => {
const target = path.join(fixture.path, "insert_only.txt")
await fs.writeFile(target, "alpha\nomega\n", "utf-8")
FileTime.read(ctx.sessionID, target)
const patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch"
@@ -142,7 +137,6 @@ describe("tool.apply_patch freeform", () => {
fn: async () => {
const target = path.join(fixture.path, "no_newline.txt")
await fs.writeFile(target, "no newline at end", "utf-8")
FileTime.read(ctx.sessionID, target)
const patchText =
"*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch"
@@ -166,7 +160,6 @@ describe("tool.apply_patch freeform", () => {
const original = path.join(fixture.path, "old", "name.txt")
await fs.mkdir(path.dirname(original), { recursive: true })
await fs.writeFile(original, "old content\n", "utf-8")
FileTime.read(ctx.sessionID, original)
const patchText =
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch"
@@ -193,7 +186,6 @@ describe("tool.apply_patch freeform", () => {
await fs.mkdir(path.dirname(destination), { recursive: true })
await fs.writeFile(original, "from\n", "utf-8")
await fs.writeFile(destination, "existing\n", "utf-8")
FileTime.read(ctx.sessionID, original)
const patchText =
"*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch"
@@ -294,7 +286,6 @@ describe("tool.apply_patch freeform", () => {
fn: async () => {
const target = path.join(fixture.path, "modify.txt")
await fs.writeFile(target, "line1\nline2\n", "utf-8")
FileTime.read(ctx.sessionID, target)
const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch"
@@ -331,7 +322,6 @@ describe("tool.apply_patch freeform", () => {
fn: async () => {
const target = path.join(fixture.path, "tail.txt")
await fs.writeFile(target, "alpha\nlast\n", "utf-8")
FileTime.read(ctx.sessionID, target)
const patchText = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch"
@@ -350,7 +340,6 @@ describe("tool.apply_patch freeform", () => {
fn: async () => {
const target = path.join(fixture.path, "two_chunks.txt")
await fs.writeFile(target, "a\nb\nc\nd\n", "utf-8")
FileTime.read(ctx.sessionID, target)
const patchText = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch"
@@ -369,7 +358,6 @@ describe("tool.apply_patch freeform", () => {
fn: async () => {
const target = path.join(fixture.path, "multi_ctx.txt")
await fs.writeFile(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n", "utf-8")
FileTime.read(ctx.sessionID, target)
const patchText = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"
@@ -389,7 +377,6 @@ describe("tool.apply_patch freeform", () => {
const target = path.join(fixture.path, "eof_anchor.txt")
// File has duplicate "marker" lines - one in middle, one at end
await fs.writeFile(target, "start\nmarker\nmiddle\nmarker\nend\n", "utf-8")
FileTime.read(ctx.sessionID, target)
// With EOF anchor, should match the LAST "marker" line, not the first
const patchText =
@@ -454,7 +441,6 @@ EOF`
const target = path.join(fixture.path, "trailing_ws.txt")
// File has trailing spaces on some lines
await fs.writeFile(target, "line1 \nline2\nline3 \n", "utf-8")
FileTime.read(ctx.sessionID, target)
// Patch doesn't have trailing spaces - should still match via rstrip pass
const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
@@ -475,7 +461,6 @@ EOF`
const target = path.join(fixture.path, "leading_ws.txt")
// File has leading spaces
await fs.writeFile(target, " line1\nline2\n line3\n", "utf-8")
FileTime.read(ctx.sessionID, target)
// Patch without leading spaces - should match via trim pass
const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch"
@@ -499,7 +484,6 @@ EOF`
const rightQuote = "\u201D"
const emDash = "\u2014"
await fs.writeFile(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`, "utf-8")
FileTime.read(ctx.sessionID, target)
// Patch uses ASCII equivalents - should match via normalized pass
// The replacement uses ASCII quotes from the patch (not preserving Unicode)
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.1.25",
"version": "1.1.28",
"type": "module",
"license": "MIT",
"scripts": {
@@ -25,4 +25,4 @@
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
}
+2 -1
View File
@@ -3,7 +3,8 @@
"name": "@opencode-ai/script",
"license": "MIT",
"devDependencies": {
"@types/bun": "catalog:"
"@types/bun": "catalog:",
"@types/semver": "catalog:"
},
"exports": {
".": "./src/index.ts"
+6 -2
View File
@@ -1,5 +1,6 @@
import { $ } from "bun"
import path from "path"
import { satisfies } from "semver"
const rootPkgPath = path.resolve(import.meta.dir, "../../../package.json")
const rootPkg = await Bun.file(rootPkgPath).json()
@@ -9,8 +10,11 @@ if (!expectedBunVersion) {
throw new Error("packageManager field not found in root package.json")
}
if (process.versions.bun !== expectedBunVersion) {
throw new Error(`This script requires bun@${expectedBunVersion}, but you are using bun@${process.versions.bun}`)
// relax version requirement
const expectedBunVersionRange = `^${expectedBunVersion}`
if (!satisfies(process.versions.bun, expectedBunVersionRange)) {
throw new Error(`This script requires bun@${expectedBunVersionRange}, but you are using bun@${process.versions.bun}`)
}
const env = {

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