Compare commits

..
167 Commits
Author SHA1 Message Date
opencode 0c48e6a116 release: v1.0.209
Update Nix Hashes / update (push) Has been cancelled
2025-12-29 23:37:04 +00:00
Adam ef266b2c74 fix(desktop): error page formatting 2025-12-29 17:33:59 -06:00
Aiden Cline 0a1cdc7a58 ci: use .env 2025-12-29 17:31:41 -06:00
Adam 2dec956a17 fix(desktop): better error messages 2025-12-29 17:29:56 -06:00
Aiden Cline ef8388f0ee Revert "feat: read global ~/.claude/skills"
This reverts commit a1c9a1b8c5.
2025-12-29 17:20:04 -06:00
opencode-agent[bot]GitHubopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-nodeAiden Cline
e5c5b5e872 docs: add run from source guide (#6405)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-12-29 17:12:42 -06:00
Aiden Cline a1c9a1b8c5 feat: read global ~/.claude/skills 2025-12-29 17:11:28 -06:00
Aiden Cline 76b012139a fix: add timeout to filewatcher subscriptions 2025-12-29 16:16:38 -06:00
Aiden Cline 02e5a19242 tweak: adjust git watcher to ignore files other than HEAD 2025-12-29 16:16:38 -06:00
af967648cb docs: opencode notificator plugin (fixed link) (#6341)
Co-authored-by: Ivan Pantic <panta@talentkit.io>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-29 16:03:35 -06:00
Dominik EngelhardtandGitHub 504a668a26 Set smallOptions for google models on openrouter (#6362) 2025-12-29 16:01:31 -06:00
jaandGitHub 5efb1c7b2d feat(highlight): add nix syntax highlighting (#6386) 2025-12-29 15:53:41 -06:00
fd973d242e fix(webfetch): make format parameter optional with markdown default (#6345)
Co-authored-by: Somair Ansar <somairansar@Somairs-MacBook-Air.local>
2025-12-29 15:53:12 -06:00
jaandGitHub c3d8672753 ci: use env vars for DRY workflow config (#6395) 2025-12-29 15:47:47 -06:00
Shoubhit DashandGitHub fe8ef041f6 add supermemory plugin to ecosystem (#6399) 2025-12-29 15:46:22 -06:00
Frank c841de947e zen: add gpt 5.1 codex mini 2025-12-29 16:44:11 -05:00
GitHub Action 825dfd48b1 chore: generate 2025-12-29 21:16:16 +00:00
Graham BennettandGitHub 923d114ffa Support different Nix store path prefixes (#6367) 2025-12-29 15:15:42 -06:00
Cole LeavittandGitHub b157fd10a7 fix: filter messages with only step-start parts in toModelMessage (#6383) 2025-12-29 14:58:11 -06:00
jaandGitHub 67ebe68160 feat(format): add nixfmt formatter for Nix files (#6380) 2025-12-29 14:57:52 -06:00
GitHub Action 7b63c14154 chore: generate 2025-12-29 20:50:47 +00:00
Adam cdc11cde2e ignore: hide provider connect button until providers loaded 2025-12-29 14:50:06 -06:00
opencode 9721223b7e release: v1.0.208
Update Nix Hashes / update (push) Has been cancelled
2025-12-29 20:44:22 +00:00
Adam 35a626e711 fix(desktop): don't flash permissions with auto-accept 2025-12-29 14:40:53 -06:00
Adam bb7b0ff221 fix(desktop): scroll sync 2025-12-29 14:36:27 -06:00
Adam 68b4038196 fix(desktop): more performance/scrolling fixes 2025-12-29 14:23:41 -06:00
Adam 3109214900 feat(desktop): auto-accept edits toggle 2025-12-29 14:23:41 -06:00
Adam 86ccc3409b fix(desktop): toast position 2025-12-29 14:23:41 -06:00
Github Action a89089c88f Update Nix flake.lock and hashes 2025-12-29 20:06:20 +00:00
CasualDeveloperandGitHub e617c5d689 fix: prevent truncated Claude streams (#6388) 2025-12-29 14:04:53 -06:00
Frank 31983ca5ff zen: do not switch provider for models require stick provider 2025-12-29 14:27:51 -05:00
Aiden Cline 59e3b7409f chore: fix type error 2025-12-29 12:38:35 -06:00
Daniel PolitoandGitHub b7ce46f7a1 Desktop: Image Preview and Dedupe File Upload (#6372) 2025-12-29 11:22:48 -06:00
Brett HeapGitHubbrettheapClaude Opus 4.5copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
82b8d8fa5d fix(tui): make auth URLs clickable regardless of line wrapping (#6317)
Co-authored-by: brettheap <brett.heap@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2025-12-29 11:21:09 -06:00
Adam 77c837eb1a fix(desktop): throttle markdown renders 2025-12-29 11:19:40 -06:00
Adam db77cc9845 chore: cleanup 2025-12-29 11:19:40 -06:00
Aiden ClineandGitHub 68043edae6 ci: changelog script update (#6371) 2025-12-29 11:12:25 -06:00
Frainer EncarnaciónandGitHub 337681dbbf fix(lsp): ESLint LSP server fails to auto-install on Windows (#6366) 2025-12-29 11:02:46 -06:00
Adam 66afc034d1 fix(desktop): don't show summary when already complete 2025-12-29 10:58:53 -06:00
Adam 11ab8de59f fix(desktop): markdown lists 2025-12-29 10:47:05 -06:00
Adam 5f074edc3a fix(desktop): performance/jankiness 2025-12-29 10:42:48 -06:00
56b5cdf883 feat: install local plugin dependencies from package.json (#6302)
Co-authored-by: OpenCode <opencode@example.com>
2025-12-29 10:37:41 -06:00
Adam fb0e1e4d8d Revert "fix(desktop): jankiness"
This reverts commit 831e9bce51.
2025-12-29 09:56:33 -06:00
Github Action b745b1593f Update Nix flake.lock and hashes 2025-12-29 15:55:48 +00:00
Adam 7376c3f8e7 feat(desktop): latex support 2025-12-29 09:54:22 -06:00
Adam 831e9bce51 fix(desktop): jankiness 2025-12-29 09:47:57 -06:00
Adam 5de73abd82 fix(desktop): markdown styles 2025-12-29 09:47:57 -06:00
Zeno JiricekandGitHub 3adbbc1b23 docs: add opencode-skillful plugin to ecosystem page (#6333) 2025-12-29 09:32:44 -06:00
Frank c6c29b3dcf zen: minimax m2.1 2025-12-29 10:16:32 -05:00
Adam a687d7c15f fix(desktop): one permission at a time 2025-12-29 09:07:36 -06:00
Daniel PolitoandGitHub 0c6da69f39 Desktop: Edit Project (#6360) 2025-12-29 08:54:49 -06:00
Adam c4930eb6b2 fix(desktop): more fine-grained state updates for permissions 2025-12-29 08:47:38 -06:00
Frank a24549fce7 docs: update MiniMax console link in integration instructions 2025-12-29 09:29:01 -05:00
Adam c0f9b13630 fix(desktop): more fine-grained state updates 2025-12-29 08:21:32 -06:00
Adam 98fd53fd5f fix(core): preserve imperative statements in summary 2025-12-29 07:25:55 -06:00
Adam 5b02a3029e fix(desktop): max height on edit tool calls 2025-12-29 07:03:44 -06:00
Frank 94e851c2a2 docs: add MiniMax integration instructions to providers documentation 2025-12-29 07:45:54 -05:00
GitHub Action 1658a3ff59 ignore: update download stats 2025-12-29 2025-12-29 12:05:06 +00:00
Adam 9c8bc64138 fix(desktop): sync last agent and model when changing session 2025-12-29 02:57:28 -06:00
Adam 80f704ebbf fix(desktop): context usage alignment 2025-12-29 02:47:51 -06:00
Matt SilverlockandGitHub 4dae6d1fcf meta: use colors for agents (#5845) 2025-12-28 23:05:17 -06:00
5d2cab39da docs: add compaction, watcher, experimental and provider options (#6304)
Co-authored-by: OpenCode <opencode@example.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-12-28 23:01:43 -06:00
GitHub Action 6963f96d4b chore: generate 2025-12-29 04:56:54 +00:00
05a9e7ce7a docs: clarify that MCP tools require glob patterns to disable (#6306)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-12-28 22:56:22 -06:00
GitHub Action 896d18ab3f chore: generate 2025-12-29 04:44:48 +00:00
Grégoire MorpainGitHubAiden Clinegithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
893888536a fix(bedrock): support region and bearer token configuration (#6332)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-28 22:44:15 -06:00
GitHub Action c6221fc8b3 chore: generate 2025-12-29 04:39:43 +00:00
Connor AdamsandGitHub ae67f43ff0 feat: add support for .claude/skills directory (#6252) 2025-12-28 22:39:10 -06:00
opencode 76880dce0d release: v1.0.207
Update Nix Hashes / update (push) Has been cancelled
2025-12-29 01:57:53 +00:00
Adam aafffb5b4b chore: cleanup 2025-12-28 19:54:22 -06:00
Adam a71c9e3f2e fix(desktop): edit diffs 2025-12-28 19:49:39 -06:00
Adam 0156f03e0e chore: cleanup theme stuff 2025-12-28 19:27:36 -06:00
Frank e0bb96a9f9 wip: bench 2025-12-28 20:00:49 -05:00
Daniel PolitoandGitHub 82e5d6d458 Desktop: Sync LSP updates (#6305) 2025-12-28 16:07:36 -06:00
Adam a4411c21b6 feat(desktop): theme preview 2025-12-28 15:47:05 -06:00
Frank 9d61370ac4 sync 2025-12-28 15:33:18 -05:00
Frank f3febd6e39 wip: benchmark 2025-12-28 14:55:05 -05:00
GitHub Action f12d55bf1e chore: generate 2025-12-28 19:13:43 +00:00
0c19b71f42 docs: add plugin configuration documentation (#6301)
Co-authored-by: OpenCode <opencode@example.com>
2025-12-28 13:13:11 -06:00
Mohak SandGitHub 70fa66397e docs: add opencode-notifier plugin to ecosystem (#6283) 2025-12-28 13:09:38 -06:00
Daniel PolitoandGitHub 6e8cd3174c Include current working directory in local MCP transport (#6303) 2025-12-28 13:09:24 -06:00
GitHub Action 5bfffbe083 chore: generate 2025-12-28 19:06:59 +00:00
Didier DurandandGitHub 29d8557d41 doc: fix typos in various files (#6294) 2025-12-28 13:06:25 -06:00
Didier Durandandopencode ffd20b4477 chore: activate code coverage in bun test config (#6297) 2025-12-28 19:05:55 +00:00
opencode 2abaa46e23 release: v1.0.206
Update Nix Hashes / update (push) Has been cancelled
2025-12-28 19:05:54 +00:00
GitHub Action 0cbbb20d22 chore: generate
publish-vscode / publish (push) Has been cancelled
Update Nix Hashes / update (push) Has been cancelled
2025-12-28 18:54:55 +00:00
Frank 81c5e7b9ed wip: benchmark 2025-12-28 13:54:11 -05:00
opencode ddf4897eaa release: v1.0.205
Update Nix Hashes / update (push) Has been cancelled
2025-12-28 18:37:58 +00:00
Adam 040939fb72 chore: cleanup theme stuff 2025-12-28 10:21:32 -06:00
Adam f89b83a6d7 chore: cleanup theme stuff 2025-12-28 10:14:30 -06:00
Adam 82a876da4d chore: cleanup 2025-12-28 06:41:59 -06:00
GitHub Action 69a15ae9c1 ignore: update download stats 2025-12-28 2025-12-28 12:04:31 +00:00
Adam 18c8e5f451 chore: cleanup 2025-12-28 05:47:22 -06:00
Adam ba3a1cfa0b chore: cleanup 2025-12-28 05:47:21 -06:00
Github Action d8563160f7 Update Nix flake.lock and hashes 2025-12-28 11:13:54 +00:00
Adam 4a9ff9412e feat(desktop): themes 2025-12-28 05:12:36 -06:00
Matt SilverlockandGitHub d6db6ff198 fix: handle non-text response parts in GitHub action (#6173) 2025-12-27 21:24:10 -06:00
Aiden Cline 79c263494f tweak: inform agent if no skills are available 2025-12-27 21:20:00 -06:00
Adam 1b5bf32ce5 chore: permissions ux 2025-12-27 20:40:25 -06:00
Adam 2e972b3fdc fix(desktop): copy/paste in terminal 2025-12-27 20:18:59 -06:00
Adam d70e9fb01e chore(desktop): cleanup 2025-12-27 19:59:16 -06:00
Adam fc082a0f14 fix(desktop): drag file over entire body to attach 2025-12-27 19:49:35 -06:00
Adam 953e4e9446 chore(desktop): vertical tabs 2025-12-27 19:43:52 -06:00
7ea0d37ee3 Thinking & tool call visibility settings for /copy and /export (#6243)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-12-27 19:32:33 -06:00
scarfandGitHub e35d97f9d7 feat: add bash shell completions (#6239) 2025-12-27 19:14:56 -06:00
GitHub Action 2c0d9a46cb chore: generate 2025-12-28 01:12:02 +00:00
2fe7a7f2d3 docs: document attach command (#6254)
Co-authored-by: Black_Fox <radekliska@gmail.com>
2025-12-27 19:11:30 -06:00
Connor AdamsandGitHub 8a2f4ddf70 chore: update INVALID_DIRS to include plural 'skills' directory (#6255) 2025-12-27 19:10:51 -06:00
processtraderGitHubopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-node
7a94d7a2c5 fix: stats command to correctly handle --days 0 for current day statistics (#6259)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2025-12-27 19:10:23 -06:00
Aiden Cline de28fafb47 fix: search all recent models instead of only top 5 in TUI /models command 2025-12-27 19:07:38 -06:00
9d485dd307 docs: add opencode-notificator to ecosystem plugins list (#6269)
Co-authored-by: Ivan Pantic <panta@talentkit.io>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-27 18:54:27 -06:00
GitHub Action 613813ac12 chore: generate 2025-12-28 00:53:48 +00:00
ewiredandGitHub 7617f59441 Allow line numbers and ranges in autocomplete (#4238) 2025-12-27 18:53:17 -06:00
opencode 7aecb43e84 release: v1.0.204
Update Nix Hashes / update (push) Has been cancelled
2025-12-27 20:51:09 +00:00
Adam 21eba5f987 feat(desktop): permissions 2025-12-27 14:43:42 -06:00
Adam c523ca4127 wip(desktop): handle more errors 2025-12-27 14:33:22 -06:00
GitHub Action 685f3ea324 ignore: update download stats 2025-12-27 2025-12-27 12:04:27 +00:00
Aiden Cline 4667d57e3c ci: stale issues 2025-12-27 00:51:05 -06:00
Didier DurandandGitHub e6b9988fa4 doc: fix typos in various files (#6238) 2025-12-27 00:46:06 -06:00
rari404andGitHub 3c02d5d338 feat: add path traversal protection to File.read and File.list (#5985) 2025-12-26 23:20:07 -06:00
Christopher OchsenreitherandGitHub bfb9787361 fix: compact command after revert now properly cleans up revert state (#6235) 2025-12-26 22:57:59 -06:00
jaandGitHub 1bcc72c477 feat: add ability to disable spinner animation (#6084) 2025-12-26 22:12:35 -06:00
Adam 4385fa4dd7 fix(desktop): prompt input fixes, directory and branch in status bar 2025-12-26 20:47:13 -06:00
Dax Raad 2b054bec95 core: fix compaction config checks to properly respect user settings 2025-12-26 19:48:56 -05:00
Dax Raad 2cdc88d295 core: add compaction config tests to verify auto and prune settings work correctly 2025-12-26 19:44:32 -05:00
GitHub Action f8fb08b3b4 chore: generate 2025-12-27 00:32:34 +00:00
Dax Raad ed06de5e30 core: add configurable compaction settings to allow users to disable auto-compaction and pruning via config instead of flags 2025-12-26 19:31:48 -05:00
Frank 52b99622ad zen: add context for login errors 2025-12-26 17:32:39 -05:00
Github Action a15397cd89 Update Nix flake.lock and hashes 2025-12-26 20:49:06 +00:00
GitHub Action da394439a1 chore: generate 2025-12-26 20:48:30 +00:00
Adam 390b0a79b3 fix(core): mdns global config 2025-12-26 14:47:53 -06:00
Adam b2f45d574f Reapply "feat(core): optional mdns service (#6192)"
This reverts commit 505068d5a6.
2025-12-26 14:47:53 -06:00
Aiden Cline 1e2ef07c97 chore: kill some unused tools 2025-12-26 14:31:22 -06:00
Aiden Cline 664e6bf2d0 test: add more tests to make sure that cwd is locked for read tool 2025-12-26 14:30:05 -06:00
Aiden Cline 160c8ab7cc tweak: bash tool description to avoid unnecessary 'cd &&' usage 2025-12-26 13:44:52 -06:00
Matt SilverlockandGitHub 1626341a4a github: support issues and workflow_dispatch events (#6157) 2025-12-26 13:34:03 -06:00
Aiden Cline 61ddd1716d ci: re-enable sync zed 2025-12-26 12:24:14 -06:00
Aiden Cline 053a10e515 ci: fix token for gh 2025-12-26 12:22:56 -06:00
Aiden Cline e1c1b1340b ci: fix var 2025-12-26 12:08:16 -06:00
Aiden Cline 7a5fbdf67c ci: update zed extension sync 2025-12-26 12:06:36 -06:00
Github Action 9afc451020 Update Nix flake.lock and hashes 2025-12-26 17:45:58 +00:00
GitHub Action f4fdf0eb03 chore: generate 2025-12-26 17:45:03 +00:00
Aiden Cline 505068d5a6 Revert "feat(core): optional mdns service (#6192)"
This reverts commit 26e7043718.
2025-12-26 11:43:52 -06:00
Aiden Cline 2e10ffac6b chore: rm comments 2025-12-26 11:43:13 -06:00
Aiden Cline 4abaa052db fix: adjust upgrade command to use gh releases page if not npm/bun/pnpm install method 2025-12-26 11:43:12 -06:00
Rohan GodhaandGitHub 1bcf8d8806 fix: opencode web baseURL error (#6181) 2025-12-26 11:36:31 -06:00
Ariane EmoryandGitHub 25c68c8061 chore: kill the dead Polaris Alpha code (#6193) 2025-12-26 11:32:31 -06:00
jaandGitHub b0e4408ecf feat: add shfmt formatter for shell scripts (#6204) 2025-12-26 11:31:51 -06:00
Aiden Cline 8416db03ef tweak: make install script handle 404s better 2025-12-26 11:28:18 -06:00
Github Action d5b47d9128 Update Nix flake.lock and hashes 2025-12-26 17:09:54 +00:00
GitHub Action 634559760a chore: generate 2025-12-26 17:09:31 +00:00
Ayush WalekarandGitHub 155ba794cf chore: createOpencodeServer expose logLevel (#6202) 2025-12-26 11:09:06 -06:00
f1ab427f0e fix(dep): Update package.json - fix perplexity provider version (#6199)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-12-26 11:08:45 -06:00
2333af6ed3 Desktop: MCP UI (#6162)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2025-12-26 10:49:05 -06:00
GitHub Action 54588b4570 chore: generate 2025-12-26 16:30:20 +00:00
26e7043718 feat(core): optional mdns service (#6192)
Co-authored-by: Github Action <action@github.com>
2025-12-26 10:29:48 -06:00
GitHub Action dd569c927a chore: generate 2025-12-26 16:22:05 +00:00
Didier DurandandGitHub cf38884778 doc: fix typos in various files (#6196) 2025-12-26 10:21:33 -06:00
GitHub Action 2946a6d9a7 ignore: update download stats 2025-12-26 2025-12-26 12:10:30 +00:00
Aiden Cline 3522c460e3 tweak: update transform for gemini models so that topP and topK match gemini-cli values 2025-12-25 22:46:12 -06:00
GitHub Action b6a264819e chore: generate 2025-12-26 04:25:19 +00:00
JackNorrisandGitHub 46c7a41d5f fix: only show diagnostics block when errors exist (#6175) 2025-12-25 22:24:48 -06:00
opencode 7cc4b24ac2 release: v1.0.203
Update Nix Hashes / update (push) Has been cancelled
2025-12-26 04:10:11 +00:00
Dax Raad 281ce4c0c3 prompt update to prevent searching via bash tool 2025-12-25 23:07:39 -05:00
Donghyun ShinandGitHub f59d274d0f fix(lsp): make JDTLS use the correct config directory on Windows (#6121) 2025-12-25 21:17:54 -06:00
GitHub Action 8886c78dce chore: generate 2025-12-26 03:05:15 +00:00
MarcoandGitHub d9f0f58277 feat: haskell lsp support (#6141) 2025-12-25 21:04:43 -06:00
194 changed files with 9938 additions and 1886 deletions
+6 -3
View File
@@ -5,6 +5,9 @@ on:
- cron: "0 */12 * * *"
workflow_dispatch:
env:
LOOKBACK_HOURS: 4
jobs:
update-docs:
if: github.repository == 'sst/opencode'
@@ -25,9 +28,9 @@ jobs:
- name: Get recent commits
id: commits
run: |
COMMITS=$(git log --since="4 hours ago" --pretty=format:"- %h %s" 2>/dev/null || echo "")
COMMITS=$(git log --since="${{ env.LOOKBACK_HOURS }} hours ago" --pretty=format:"- %h %s" 2>/dev/null || echo "")
if [ -z "$COMMITS" ]; then
echo "No commits in the last 4 hours"
echo "No commits in the last ${{ env.LOOKBACK_HOURS }} hours"
echo "has_commits=false" >> $GITHUB_OUTPUT
else
echo "has_commits=true" >> $GITHUB_OUTPUT
@@ -47,7 +50,7 @@ jobs:
model: opencode/gpt-5.2
agent: docs
prompt: |
Review the following commits from the last 4 hours and identify any new features that may need documentation.
Review the following commits from the last ${{ env.LOOKBACK_HOURS }} hours and identify any new features that may need documentation.
<recent_commits>
${{ steps.commits.outputs.list }}
+1 -1
View File
@@ -64,7 +64,7 @@ jobs:
Please check all the code changes in this pull request against the style guide, also look for any bugs if they exist. Diffs are important but make sure you read the entire file to get proper context. Make it clear the suggestions are merely suggestions and the human can decide what to do
When critiquing code against the style guide, be sure that the code is ACTUALLY in violation, don't complain about else statements if they already use early returns there. You may complain about excessive nesting though, regardless of else statement usage.
When critiquing code style don't be a zealot, we don't like "let" statements but sometimes they are the simpliest option, if someone does a bunch of nesting with let, they should consider using iife (see packages/opencode/src/util.iife.ts)
When critiquing code style don't be a zealot, we don't like "let" statements but sometimes they are the simplest option, if someone does a bunch of nesting with let, they should consider using iife (see packages/opencode/src/util.iife.ts)
Use the gh cli to create comments on the files for the violations. Try to leave the comment on the exact line number. If you have a suggested fix include it in a suggestion code block.
If you are writing suggested fixes, BE SURE THAT the change you are recommending is actually valid typescript, often I have seen missing closing "}" or other syntax errors.
+33
View File
@@ -0,0 +1,33 @@
name: "Auto-close stale issues"
on:
schedule:
- cron: "30 1 * * *" # Daily at 1:30 AM
workflow_dispatch:
env:
DAYS_BEFORE_STALE: 90
DAYS_BEFORE_CLOSE: 7
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/stale@v10
with:
days-before-stale: ${{ env.DAYS_BEFORE_STALE }}
days-before-close: ${{ env.DAYS_BEFORE_CLOSE }}
stale-issue-label: "stale"
close-issue-message: |
[automated] Closing due to ${{ env.DAYS_BEFORE_STALE }}+ days of inactivity.
Feel free to reopen if you still need this!
stale-issue-message: |
[automated] This issue has had no activity for ${{ env.DAYS_BEFORE_STALE }} days.
It will be closed in ${{ env.DAYS_BEFORE_CLOSE }} days if there's no new activity.
remove-stale-when-updated: true
exempt-issue-labels: "pinned,security,feature-request,on-hold"
start-date: "2025-12-27"
+3 -3
View File
@@ -2,8 +2,8 @@ name: "sync-zed-extension"
on:
workflow_dispatch:
# release:
# types: [published]
release:
types: [published]
jobs:
zed:
@@ -31,4 +31,4 @@ jobs:
run: |
./script/sync-zed.ts ${{ steps.get_tag.outputs.tag }}
env:
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
ZED_EXTENSIONS_PAT: ${{ secrets.ZED_EXTENSIONS_PAT }}
+4
View File
@@ -20,3 +20,7 @@ opencode.json
a.out
target
.scripts
# Local dev files
opencode-dev
logs/
+1
View File
@@ -1,5 +1,6 @@
---
description: ALWAYS use this when writing docs
color: "#38A3EE"
---
You are an expert technical documentation writer
+1
View File
@@ -2,6 +2,7 @@
mode: primary
hidden: true
model: opencode/claude-haiku-4-5
color: "#44BA81"
tools:
"*": false
"github-triage": true
+30
View File
@@ -34,6 +34,36 @@ Want to take on an issue? Leave a comment and a maintainer may assign it to you
bun dev
```
### Running against a different directory
By default, `bun dev` runs OpenCode in the `packages/opencode` directory. To run it against a different directory or repository:
```bash
bun dev <directory>
```
To run OpenCode in the root of the opencode repo itself:
```bash
bun dev .
```
### Building a "localcode"
To compile a standalone executable:
```bash
./packages/opencode/script/build.ts --single
```
Then run it with:
```bash
./packages/opencode/dist/opencode-<platform>/bin/opencode
```
Replace `<platform>` with your platform (e.g., `darwin-arm64`, `linux-x64`).
- Core pieces:
- `packages/opencode`: OpenCode core business logic & server.
- `packages/opencode/src/cli/cmd/tui/`: The TUI code, written in SolidJS with [opentui](https://github.com/sst/opentui)
+2 -2
View File
@@ -79,7 +79,7 @@ you can switch between these using the `Tab` key.
- Asks permission before running bash commands
- Ideal for exploring unfamiliar codebases or planning changes
Also, included is a **general** subagent for complex searches and multi-step tasks.
Also, included is a **general** subagent for complex searches and multistep tasks.
This is used internally and can be invoked using `@general` in messages.
Learn more about [agents](https://opencode.ai/docs/agents).
@@ -98,7 +98,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
### FAQ
#### How is this different than Claude Code?
#### How is this different from Claude Code?
It's very similar to Claude Code in terms of capability. Here are the key differences:
+4
View File
@@ -181,3 +181,7 @@
| 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) |
| 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) |
| 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) |
| 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) |
| 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) |
| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) |
| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) |
+50 -42
View File
@@ -5,13 +5,6 @@
"": {
"name": "opencode",
"dependencies": {
"@ai-sdk/cerebras": "1.0.33",
"@ai-sdk/cohere": "2.0.21",
"@ai-sdk/deepinfra": "1.0.30",
"@ai-sdk/gateway": "2.0.23",
"@ai-sdk/groq": "2.0.33",
"@ai-sdk/perplexity": "2.0.22",
"@ai-sdk/togetherai": "1.0.30",
"@aws-sdk/client-s3": "3.933.0",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
@@ -29,7 +22,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.0.202",
"version": "1.0.209",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -77,7 +70,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.0.202",
"version": "1.0.209",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -105,7 +98,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.0.202",
"version": "1.0.209",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -132,7 +125,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.0.202",
"version": "1.0.209",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -156,7 +149,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.0.202",
"version": "1.0.209",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -180,7 +173,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.0.202",
"version": "1.0.209",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@solid-primitives/storage": "catalog:",
@@ -207,7 +200,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.0.202",
"version": "1.0.209",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -236,7 +229,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.0.202",
"version": "1.0.209",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -252,7 +245,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.0.202",
"version": "1.0.209",
"bin": {
"opencode": "./bin/opencode",
},
@@ -261,16 +254,23 @@
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.5.1",
"@ai-sdk/amazon-bedrock": "3.0.57",
"@ai-sdk/anthropic": "2.0.50",
"@ai-sdk/anthropic": "2.0.56",
"@ai-sdk/azure": "2.0.73",
"@ai-sdk/cerebras": "1.0.33",
"@ai-sdk/cohere": "2.0.21",
"@ai-sdk/deepinfra": "1.0.30",
"@ai-sdk/gateway": "2.0.23",
"@ai-sdk/google": "2.0.44",
"@ai-sdk/google-vertex": "3.0.81",
"@ai-sdk/groq": "2.0.33",
"@ai-sdk/mcp": "0.0.8",
"@ai-sdk/mistral": "2.0.26",
"@ai-sdk/openai": "2.0.71",
"@ai-sdk/openai-compatible": "1.0.27",
"@ai-sdk/perplexity": "2.0.22",
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.18",
"@ai-sdk/provider-utils": "3.0.19",
"@ai-sdk/togetherai": "1.0.30",
"@ai-sdk/xai": "2.0.42",
"@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5",
@@ -292,6 +292,7 @@
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"bonjour-service": "1.3.0",
"bun-pty": "0.4.2",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
@@ -346,7 +347,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.0.202",
"version": "1.0.209",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -366,7 +367,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.0.202",
"version": "1.0.209",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -377,7 +378,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.0.202",
"version": "1.0.209",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -390,7 +391,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.0.202",
"version": "1.0.209",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -402,8 +403,10 @@
"@solidjs/meta": "catalog:",
"@typescript/native-preview": "catalog:",
"fuzzysort": "catalog:",
"katex": "0.16.27",
"luxon": "catalog:",
"marked": "catalog:",
"marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:",
"remeda": "catalog:",
"shiki": "catalog:",
@@ -415,6 +418,7 @@
"@tailwindcss/vite": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/katex": "0.16.7",
"@types/luxon": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:",
@@ -425,7 +429,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.0.202",
"version": "1.0.209",
"dependencies": {
"zod": "catalog:",
},
@@ -436,7 +440,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.0.202",
"version": "1.0.209",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -565,7 +569,7 @@
"@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.18", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.30", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.29", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9bxQbIXnWSN4bNismrza3NvIo+ui/Y3pj3UN6e9vCszCWFCN45RgISi4oDe10RqmzaJ/X8cfO/Tem+K8MT3wGQ=="],
@@ -1081,6 +1085,8 @@
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
"@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="],
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="],
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
@@ -1771,6 +1777,8 @@
"@types/jsonwebtoken": ["@types/jsonwebtoken@8.5.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg=="],
"@types/katex": ["@types/katex@0.16.7", "", {}, "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="],
"@types/luxon": ["@types/luxon@3.7.1", "", {}, "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
@@ -2003,6 +2011,8 @@
"body-parser": ["body-parser@1.20.3", "", { "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g=="],
"bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="],
@@ -2247,6 +2257,8 @@
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"dns-packet": ["dns-packet@5.6.1", "", { "dependencies": { "@leichtgewicht/ip-codec": "^2.0.1" } }, "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
@@ -2779,6 +2791,8 @@
"jwt-decode": ["jwt-decode@3.1.2", "", {}, "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="],
"katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="],
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
@@ -2869,6 +2883,8 @@
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="],
"marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -3023,6 +3039,8 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"multicast-dns": ["multicast-dns@7.2.5", "", { "dependencies": { "dns-packet": "^5.2.2", "thunky": "^1.0.2" }, "bin": { "multicast-dns": "cli.js" } }, "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg=="],
"mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="],
"mysql2": ["mysql2@3.14.4", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-Cs/jx3WZPNrYHVz+Iunp9ziahaG5uFMvD2R8Zlmc194AqXNxt9HBNu7ZsPYrUtmJsF0egETCWIdMIYAwOGjL1w=="],
@@ -3595,6 +3613,8 @@
"three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="],
"thunky": ["thunky@1.1.0", "", {}, "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="],
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
@@ -3887,38 +3907,24 @@
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
"@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"@ai-sdk/cohere/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
"@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.18", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.50", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-21PaHfoLmouOXXNINTsZJsMw+wE5oLR2He/1kq/sKokTVKyq7ObGT1LDk6ahwxaz/GoaNaGankMh+EgVcdv2Cw=="],
"@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.18", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ=="],
"@ai-sdk/mcp/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="],
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
"@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
"@ai-sdk/togetherai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
"@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"@astrojs/cloudflare/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
"@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
@@ -4273,6 +4279,8 @@
"jsonwebtoken/jws": ["jws@3.2.2", "", { "dependencies": { "jwa": "^1.4.1", "safe-buffer": "^5.0.1" } }, "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA=="],
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -4297,7 +4305,7 @@
"nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.50", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-21PaHfoLmouOXXNINTsZJsMw+wE5oLR2He/1kq/sKokTVKyq7ObGT1LDk6ahwxaz/GoaNaGankMh+EgVcdv2Cw=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@2.0.71", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tg+gj+R0z/On9P4V7hy7/7o04cQPjKGayMCL3gzWD/aNGjAKkhEnaocuNDidSnghizt8g2zJn16cAuAolnW+qQ=="],
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1766532406,
"narHash": "sha256-acLU/ag9VEoKkzOD202QASX25nG1eArXg5A0mHjKgxM=",
"lastModified": 1766996594,
"narHash": "sha256-SosfgQSqVmOkqVgNYJnxW5FvoIQX4grOcpIKNrIwz4o=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "8142186f001295e5a3239f485c8a49bf2de2695a",
"rev": "0744ef1b047f07d31d9962d757ffe38ec14a4d41",
"type": "github"
},
"original": {
+10
View File
@@ -155,8 +155,18 @@ if [ -z "$requested_version" ]; then
exit 1
fi
else
# Strip leading 'v' if present
requested_version="${requested_version#v}"
url="https://github.com/sst/opencode/releases/download/v${requested_version}/$filename"
specific_version=$requested_version
# Verify the release exists before downloading
http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/sst/opencode/releases/tag/v${requested_version}")
if [ "$http_status" = "404" ]; then
echo -e "${RED}Error: Release v${requested_version} not found${NC}"
echo -e "${MUTED}Available releases: https://github.com/sst/opencode/releases${NC}"
exit 1
fi
fi
print_message() {
+1 -1
View File
@@ -1,3 +1,3 @@
{
"nodeModules": "sha256-hotsyeWJA6/dP6DvZTN1Ak2RSKcsyvXlXPI/jexBHME="
"nodeModules": "sha256-2i/QMBzp9MalOXur36mXaDDU8R9G/0dODCODEQOnaCU="
}
+7 -3
View File
@@ -31,9 +31,13 @@ for (const [name, wasmPath] of byName) {
next = next.replaceAll("tree-sitter.wasm", mainWasm).replaceAll("web-tree-sitter/tree-sitter.wasm", mainWasm)
// Collapse any relative prefixes before absolute store paths (e.g., "../../../..//nix/store/...")
const nixStorePrefix = process.env.NIX_STORE || "/nix/store"
next = next.replace(/(\.\/)+/g, "./")
next = next.replace(/(\.\.\/)+\/?(\/nix\/store[^"']+)/g, "/$2")
next = next.replace(/(["'])\/{2,}(\/nix\/store[^"']+)(["'])/g, "$1/$2$3")
next = next.replace(/(["'])\/\/(nix\/store[^"']+)(["'])/g, "$1/$2$3")
next = next.replace(
new RegExp(`(\\.\\.\\/)+\\/{1,2}(${nixStorePrefix.replace(/^\//, "").replace(/\//g, "\\/")}[^"']+)`, "g"),
"/$2",
)
next = next.replace(new RegExp(`(["'])\\/{2,}(\\/${nixStorePrefix.replace(/\//g, "\\/")}[^"']+)(["'])`, "g"), "$1$2$3")
next = next.replace(new RegExp(`(["'])\\/\\/(${nixStorePrefix.replace(/\//g, "\\/")}[^"']+)(["'])`, "g"), "$1$2$3")
if (next !== content) fs.writeFileSync(file, next)
-7
View File
@@ -67,13 +67,6 @@
"turbo": "2.5.6"
},
"dependencies": {
"@ai-sdk/cerebras": "1.0.33",
"@ai-sdk/cohere": "2.0.21",
"@ai-sdk/deepinfra": "1.0.30",
"@ai-sdk/gateway": "2.0.23",
"@ai-sdk/groq": "2.0.33",
"@ai-sdk/perplexity": "2.0.22",
"@ai-sdk/togetherai": "1.0.30",
"@aws-sdk/client-s3": "3.933.0",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
+31 -6
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="en" style="background-color: var(--background-base)">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -13,14 +13,39 @@
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<script>
<!-- Theme preload script - applies cached theme to avoid FOUC -->
<script id="oc-theme-preload-script">
;(function () {
const savedTheme = localStorage.getItem("theme") || "oc-1"
document.documentElement.setAttribute("data-theme", savedTheme)
var themeId = localStorage.getItem("opencode-theme-id")
if (!themeId) return
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
var mode = isDark ? "dark" : "light"
document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode
if (themeId === "oc-1") return
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
if (css) {
var style = document.createElement("style")
style.id = "oc-theme-preload"
style.textContent =
":root{color-scheme:" +
mode +
";--text-mix-blend-mode:" +
(isDark ? "plus-lighter" : "multiply") +
";" +
css +
"}"
document.head.appendChild(style)
}
})()
</script>
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-screen"></div>
<script src="/src/entry.tsx" type="module"></script>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.0.202",
"version": "1.0.209",
"description": "",
"type": "module",
"exports": {
+46 -43
View File
@@ -8,6 +8,7 @@ import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { Diff } from "@opencode-ai/ui/diff"
import { Code } from "@opencode-ai/ui/code"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { GlobalSyncProvider } from "@/context/global-sync"
import { LayoutProvider } from "@/context/layout"
import { GlobalSDKProvider } from "@/context/global-sdk"
@@ -38,55 +39,57 @@ const url = iife(() => {
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return "http://localhost:4096"
return window.location.origin
})
export function App() {
return (
<MetaProvider>
<Font />
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<DialogProvider>
<MarkedProvider>
<DiffComponentProvider component={Diff}>
<CodeComponentProvider component={Code}>
<GlobalSDKProvider url={url}>
<GlobalSyncProvider>
<LayoutProvider>
<NotificationProvider>
<Router
root={(props) => (
<CommandProvider>
<Layout>{props.children}</Layout>
</CommandProvider>
)}
>
<Route path="/" component={Home} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id || true} keyed>
<TerminalProvider>
<PromptProvider>
<Session />
</PromptProvider>
</TerminalProvider>
</Show>
)}
/>
</Route>
</Router>
</NotificationProvider>
</LayoutProvider>
</GlobalSyncProvider>
</GlobalSDKProvider>
</CodeComponentProvider>
</DiffComponentProvider>
</MarkedProvider>
</DialogProvider>
</ErrorBoundary>
<ThemeProvider>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<DialogProvider>
<MarkedProvider>
<DiffComponentProvider component={Diff}>
<CodeComponentProvider component={Code}>
<GlobalSDKProvider url={url}>
<GlobalSyncProvider>
<LayoutProvider>
<NotificationProvider>
<Router
root={(props) => (
<CommandProvider>
<Layout>{props.children}</Layout>
</CommandProvider>
)}
>
<Route path="/" component={Home} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id ?? "new"} keyed>
<TerminalProvider>
<PromptProvider>
<Session />
</PromptProvider>
</TerminalProvider>
</Show>
)}
/>
</Route>
</Router>
</NotificationProvider>
</LayoutProvider>
</GlobalSyncProvider>
</GlobalSDKProvider>
</CodeComponentProvider>
</DiffComponentProvider>
</MarkedProvider>
</DialogProvider>
</ErrorBoundary>
</ThemeProvider>
</MetaProvider>
)
}
@@ -0,0 +1,180 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { TextField } from "@opencode-ai/ui/text-field"
import { Icon } from "@opencode-ai/ui/icon"
import { createMemo, createSignal, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobalSDK } from "@/context/global-sdk"
import { type LocalProject, getAvatarColors } from "@/context/layout"
import { Avatar } from "@opencode-ai/ui/avatar"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
function getFilename(input: string) {
const parts = input.split("/")
return parts[parts.length - 1] || input
}
export function DialogEditProject(props: { project: LocalProject }) {
const dialog = useDialog()
const globalSDK = useGlobalSDK()
const folderName = createMemo(() => getFilename(props.project.worktree))
const defaultName = createMemo(() => props.project.name || folderName())
const [store, setStore] = createStore({
name: defaultName(),
color: props.project.icon?.color || "pink",
iconUrl: props.project.icon?.url || "",
saving: false,
})
const [dragOver, setDragOver] = createSignal(false)
function handleFileSelect(file: File) {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
reader.onload = (e) => setStore("iconUrl", e.target?.result as string)
reader.readAsDataURL(file)
}
function handleDrop(e: DragEvent) {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer?.files[0]
if (file) handleFileSelect(file)
}
function handleDragOver(e: DragEvent) {
e.preventDefault()
setDragOver(true)
}
function handleDragLeave() {
setDragOver(false)
}
function handleInputChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (file) handleFileSelect(file)
}
function clearIcon() {
setStore("iconUrl", "")
}
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
if (!props.project.id) return
setStore("saving", true)
const name = store.name.trim() === folderName() ? "" : store.name.trim()
await globalSDK.client.project.update({
projectID: props.project.id,
name,
icon: { color: store.color, url: store.iconUrl },
})
setStore("saving", false)
dialog.close()
}
return (
<Dialog title="Edit project">
<form onSubmit={handleSubmit} class="flex flex-col gap-6 px-2.5 pb-3">
<div class="flex flex-col gap-4">
<TextField
autofocus
type="text"
label="Name"
placeholder={folderName()}
value={store.name}
onChange={(v) => setStore("name", v)}
/>
<div class="flex flex-col gap-2">
<label class="text-12-medium text-text-weak">Icon</label>
<div class="flex gap-3 items-start">
<div class="relative">
<div
class="size-16 rounded-lg overflow-hidden border border-dashed transition-colors cursor-pointer"
classList={{
"border-text-interactive-base bg-surface-info-base/20": dragOver(),
"border-border-base hover:border-border-strong": !dragOver(),
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => document.getElementById("icon-upload")?.click()}
>
<Show
when={store.iconUrl}
fallback={
<div class="size-full flex items-center justify-center">
<Avatar
fallback={store.name || defaultName()}
{...getAvatarColors(store.color)}
class="size-full"
/>
</div>
}
>
<img src={store.iconUrl} alt="Project icon" class="size-full object-cover" />
</Show>
</div>
<Show when={store.iconUrl}>
<button
type="button"
class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-base border border-border-base flex items-center justify-center hover:bg-surface-raised-base-hover"
onClick={clearIcon}
>
<Icon name="close" class="size-3 text-icon-base" />
</button>
</Show>
</div>
<input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak">
<span>Click or drag an image</span>
<span>Recommended: 128x128px</span>
</div>
</div>
</div>
<Show when={!store.iconUrl}>
<div class="flex flex-col gap-2">
<label class="text-12-medium text-text-weak">Color</label>
<div class="flex gap-2">
<For each={AVATAR_COLOR_KEYS}>
{(color) => (
<button
type="button"
class="relative size-8 rounded-md transition-all"
classList={{
"ring-2 ring-offset-2 ring-offset-surface-base ring-text-interactive-base":
store.color === color,
}}
style={{ background: getAvatarColors(color).background }}
onClick={() => setStore("color", color)}
>
<Avatar fallback={store.name || defaultName()} {...getAvatarColors(color)} class="size-full" />
</button>
)}
</For>
</div>
</div>
</Show>
</div>
<div class="flex justify-end gap-2">
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
Cancel
</Button>
<Button type="submit" variant="primary" size="large" disabled={store.saving}>
{store.saving ? "Saving..." : "Save"}
</Button>
</div>
</form>
</Dialog>
)
}
@@ -0,0 +1,91 @@
import { Component, createMemo, createSignal, Show } from "solid-js"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch"
export const DialogSelectMcp: Component = () => {
const sync = useSync()
const sdk = useSDK()
const [loading, setLoading] = createSignal<string | null>(null)
const items = createMemo(() =>
Object.entries(sync.data.mcp ?? {})
.map(([name, status]) => ({ name, status: status.status }))
.sort((a, b) => a.name.localeCompare(b.name)),
)
const toggle = async (name: string) => {
if (loading()) return
setLoading(name)
const status = sync.data.mcp[name]
if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name })
} else {
await sdk.client.mcp.connect({ name })
}
const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data)
setLoading(null)
}
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
const totalCount = createMemo(() => items().length)
return (
<Dialog title="MCPs" description={`${enabledCount()} of ${totalCount()} enabled`}>
<List
search={{ placeholder: "Search", autofocus: true }}
emptyMessage="No MCPs configured"
key={(x) => x?.name ?? ""}
items={items}
filterKeys={["name", "status"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
onSelect={(x) => {
if (x) toggle(x.name)
}}
>
{(i) => {
const mcpStatus = () => sync.data.mcp[i.name]
const status = () => mcpStatus()?.status
const error = () => {
const s = mcpStatus()
return s?.status === "failed" ? s.error : undefined
}
const enabled = () => status() === "connected"
return (
<div class="w-full flex items-center justify-between gap-x-3">
<div class="flex flex-col gap-0.5 min-w-0">
<div class="flex items-center gap-2">
<span class="truncate">{i.name}</span>
<Show when={status() === "connected"}>
<span class="text-11-regular text-text-weaker">connected</span>
</Show>
<Show when={status() === "failed"}>
<span class="text-11-regular text-text-weaker">failed</span>
</Show>
<Show when={status() === "needs_auth"}>
<span class="text-11-regular text-text-weaker">needs auth</span>
</Show>
<Show when={status() === "disabled"}>
<span class="text-11-regular text-text-weaker">disabled</span>
</Show>
<Show when={loading() === i.name}>
<span class="text-11-regular text-text-weak">...</span>
</Show>
</div>
<Show when={error()}>
<span class="text-11-regular text-text-weaker truncate">{error()}</span>
</Show>
</div>
<div onClick={(e) => e.stopPropagation()}>
<Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} />
</div>
</div>
)
}}
</List>
</Dialog>
)
}
+4
View File
@@ -188,6 +188,10 @@ export function Header(props: {
shareURL = await globalSDK.client.session
.share({ sessionID: session.id, directory: currentDirectory() })
.then((r) => r.data?.share?.url)
.catch((e) => {
console.error("Failed to share session", e)
return undefined
})
}
return shareURL
},
+195 -47
View File
@@ -82,6 +82,37 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const command = useCommand()
let editorRef!: HTMLDivElement
let fileInputRef!: HTMLInputElement
let scrollRef!: HTMLDivElement
const scrollCursorIntoView = () => {
const container = scrollRef
const selection = window.getSelection()
if (!container || !selection || selection.rangeCount === 0) return
const range = selection.getRangeAt(0)
if (!editorRef.contains(range.startContainer)) return
const rect = range.getBoundingClientRect()
if (!rect.height) return
const containerRect = container.getBoundingClientRect()
const top = rect.top - containerRect.top + container.scrollTop
const bottom = rect.bottom - containerRect.top + container.scrollTop
const padding = 12
if (top < container.scrollTop + padding) {
container.scrollTop = Math.max(0, top - padding)
return
}
if (bottom > container.scrollTop + container.clientHeight - padding) {
container.scrollTop = bottom - container.clientHeight + padding
}
}
const queueScroll = () => {
requestAnimationFrame(scrollCursorIntoView)
}
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
@@ -153,6 +184,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
editorRef.focus()
setCursorPosition(editorRef, length)
setStore("applyingHistory", false)
queueScroll()
})
}
@@ -216,6 +248,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const handlePaste = async (event: ClipboardEvent) => {
if (!isFocused()) return
const clipboardData = event.clipboardData
if (!clipboardData) return
@@ -238,7 +271,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
addPart({ type: "text", content: plainText, start: 0, end: 0 })
}
const handleDragOver = (event: DragEvent) => {
const handleGlobalDragOver = (event: DragEvent) => {
event.preventDefault()
const hasFiles = event.dataTransfer?.types.includes("Files")
if (hasFiles) {
@@ -246,15 +279,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
const handleDragLeave = (event: DragEvent) => {
const related = event.relatedTarget as Node | null
const form = event.currentTarget as HTMLElement
if (!related || !form.contains(related)) {
const handleGlobalDragLeave = (event: DragEvent) => {
// relatedTarget is null when leaving the document window
if (!event.relatedTarget) {
setStore("dragging", false)
}
}
const handleDrop = async (event: DragEvent) => {
const handleGlobalDrop = async (event: DragEvent) => {
event.preventDefault()
setStore("dragging", false)
@@ -270,9 +302,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onMount(() => {
editorRef.addEventListener("paste", handlePaste)
document.addEventListener("dragover", handleGlobalDragOver)
document.addEventListener("dragleave", handleGlobalDragLeave)
document.addEventListener("drop", handleGlobalDrop)
})
onCleanup(() => {
editorRef.removeEventListener("paste", handlePaste)
document.removeEventListener("dragover", handleGlobalDragOver)
document.removeEventListener("dragleave", handleGlobalDragLeave)
document.removeEventListener("drop", handleGlobalDrop)
})
createEffect(() => {
@@ -357,9 +395,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
(currentParts) => {
const domParts = parseFromDOM()
const normalized = Array.from(editorRef.childNodes).every((node) => {
if (node.nodeType === Node.TEXT_NODE) return true
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent ?? ""
if (!text.includes("\u200B")) return true
if (text !== "\u200B") return false
const prev = node.previousSibling
const next = node.nextSibling
const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR"
const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR"
if (!prevIsBr && !nextIsBr) return false
if (nextIsBr && !prevIsBr && prev) return false
return true
}
if (node.nodeType !== Node.ELEMENT_NODE) return false
return (node as HTMLElement).dataset.type === "file"
const el = node as HTMLElement
if (el.dataset.type === "file") return true
return el.tagName === "BR"
})
if (normalized && isPromptEqual(currentParts, domParts)) return
@@ -372,7 +424,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
editorRef.innerHTML = ""
currentParts.forEach((part) => {
if (part.type === "text") {
editorRef.appendChild(document.createTextNode(part.content))
editorRef.appendChild(createTextFragment(part.content))
} else if (part.type === "file") {
const pill = document.createElement("span")
pill.textContent = part.content
@@ -398,7 +450,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let buffer = ""
const flushText = () => {
const content = buffer.replace(/\r\n?/g, "\n")
const content = buffer.replace(/\r\n?/g, "\n").replace(/\u200B/g, "")
buffer = ""
if (!content) return
parts.push({ type: "text", content, start: position, end: position + content.length })
@@ -472,6 +524,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (prompt.dirty()) {
prompt.set(DEFAULT_PROMPT, 0)
}
queueScroll()
return
}
@@ -500,6 +553,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
prompt.set(rawParts, cursorPosition)
queueScroll()
}
const addPart = (part: ContentPart) => {
@@ -529,9 +583,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const nodes = Array.from(editorRef.childNodes)
for (const node of nodes) {
const length = node.textContent?.length ?? 0
const length = getNodeLength(node)
const isText = node.nodeType === Node.TEXT_NODE
const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
if (isText && remaining <= length) {
if (edge === "start") range.setStart(node, remaining)
@@ -539,7 +594,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}
if (isFile && remaining <= length) {
if ((isFile || isBreak) && remaining <= length) {
if (edge === "start" && remaining === 0) range.setStartBefore(node)
if (edge === "start" && remaining > 0) range.setStartAfter(node)
if (edge === "end" && remaining === 0) range.setEndBefore(node)
@@ -565,11 +620,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
selection.removeAllRanges()
selection.addRange(range)
} else if (part.type === "text") {
const textNode = document.createTextNode(part.content)
const range = selection.getRangeAt(0)
const fragment = createTextFragment(part.content)
const last = fragment.lastChild
range.deleteContents()
range.insertNode(textNode)
range.setStartAfter(textNode)
range.insertNode(fragment)
if (last) {
if (last.nodeType === Node.TEXT_NODE) {
const text = last.textContent ?? ""
if (text === "\u200B") {
range.setStart(last, 0)
}
if (text !== "\u200B") {
range.setStart(last, text.length)
}
}
if (last.nodeType !== Node.TEXT_NODE) {
range.setStartAfter(last)
}
}
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
@@ -580,9 +649,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const abort = () =>
sdk.client.session.abort({
sessionID: params.id!,
})
sdk.client.session
.abort({
sessionID: params.id!,
})
.catch(() => {})
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
const text = prompt
@@ -646,6 +717,24 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Backspace") {
const selection = window.getSelection()
if (selection && selection.isCollapsed) {
const node = selection.anchorNode
const offset = selection.anchorOffset
if (node && node.nodeType === Node.TEXT_NODE) {
const text = node.textContent ?? ""
if (/^\u200B+$/.test(text) && offset > 0) {
const range = document.createRange()
range.setStart(node, 0)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
}
}
}
}
if (event.key === "!" && store.mode === "normal") {
const cursorPosition = getCursorPosition(editorRef)
if (cursorPosition === 0) {
@@ -686,7 +775,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const cursorPosition = getCursorPosition(editorRef)
const textLength = promptLength(prompt.current())
const textContent = editorRef.textContent ?? ""
const textContent = prompt
.current()
.map((part) => ("content" in part ? part.content : ""))
.join("")
const isEmpty = textContent.trim() === "" || textLength <= 1
const hasNewlines = textContent.includes("\n")
const inHistory = store.historyIndex >= 0
@@ -799,12 +891,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const agent = local.agent.current()!.name
if (isShellMode) {
sdk.client.session.shell({
sessionID: existing.id,
agent,
model,
command: text,
})
sdk.client.session
.shell({
sessionID: existing.id,
agent,
model,
command: text,
})
.catch((e) => {
console.error("Failed to send shell command", e)
})
return
}
@@ -813,13 +909,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const commandName = cmdName.slice(1)
const customCommand = sync.data.command.find((c) => c.name === commandName)
if (customCommand) {
sdk.client.session.command({
sessionID: existing.id,
command: commandName,
arguments: args.join(" "),
agent,
model: `${model.providerID}/${model.modelID}`,
})
sdk.client.session
.command({
sessionID: existing.id,
command: commandName,
arguments: args.join(" "),
agent,
model: `${model.providerID}/${model.modelID}`,
})
.catch((e) => {
console.error("Failed to send command", e)
})
return
}
}
@@ -845,13 +945,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
model,
})
sdk.client.session.prompt({
sessionID: existing.id,
agent,
model,
messageID,
parts: requestParts,
})
sdk.client.session
.prompt({
sessionID: existing.id,
agent,
model,
messageID,
parts: requestParts,
})
.catch((e) => {
console.error("Failed to send prompt", e)
})
}
return (
@@ -926,9 +1030,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Show>
<form
onSubmit={handleSubmit}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
classList={{
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
"rounded-md overflow-clip focus-within:shadow-xs-border": true,
@@ -978,7 +1079,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</For>
</div>
</Show>
<div class="relative max-h-[240px] overflow-y-auto">
<div class="relative max-h-[240px] overflow-y-auto" ref={(el) => (scrollRef = el)}>
<div
data-component="prompt-input"
ref={(el) => {
@@ -1119,23 +1220,56 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
)
}
function createTextFragment(content: string): DocumentFragment {
const fragment = document.createDocumentFragment()
const segments = content.split("\n")
segments.forEach((segment, index) => {
if (segment) {
fragment.appendChild(document.createTextNode(segment))
} else if (segments.length > 1) {
fragment.appendChild(document.createTextNode("\u200B"))
}
if (index < segments.length - 1) {
fragment.appendChild(document.createElement("br"))
}
})
return fragment
}
function getNodeLength(node: Node): number {
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
return (node.textContent ?? "").replace(/\u200B/g, "").length
}
function getTextLength(node: Node): number {
if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
let length = 0
for (const child of Array.from(node.childNodes)) {
length += getTextLength(child)
}
return length
}
function getCursorPosition(parent: HTMLElement): number {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return 0
const range = selection.getRangeAt(0)
if (!parent.contains(range.startContainer)) return 0
const preCaretRange = range.cloneRange()
preCaretRange.selectNodeContents(parent)
preCaretRange.setEnd(range.startContainer, range.startOffset)
return preCaretRange.toString().length
return getTextLength(preCaretRange.cloneContents())
}
function setCursorPosition(parent: HTMLElement, position: number) {
let remaining = position
let node = parent.firstChild
while (node) {
const length = node.textContent ? node.textContent.length : 0
const length = getNodeLength(node)
const isText = node.nodeType === Node.TEXT_NODE
const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
if (isText && remaining <= length) {
const range = document.createRange()
@@ -1147,10 +1281,24 @@ function setCursorPosition(parent: HTMLElement, position: number) {
return
}
if (isFile && remaining <= length) {
if ((isFile || isBreak) && remaining <= length) {
const range = document.createRange()
const selection = window.getSelection()
range.setStartAfter(node)
if (remaining === 0) {
range.setStartBefore(node)
}
if (remaining > 0 && isFile) {
range.setStartAfter(node)
}
if (remaining > 0 && isBreak) {
const next = node.nextSibling
if (next && next.nodeType === Node.TEXT_NODE) {
range.setStart(next, 0)
}
if (!next || next.nodeType !== Node.TEXT_NODE) {
range.setStartAfter(node)
}
}
range.collapse(true)
selection?.removeAllRanges()
selection?.addRange(range)
@@ -3,7 +3,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { useSync } from "@/context/sync"
import { useParams } from "@solidjs/router"
import { AssistantMessage } from "@opencode-ai/sdk/v2"
import { AssistantMessage } from "@opencode-ai/sdk/v2/client"
export function SessionContextUsage() {
const sync = useSync()
@@ -35,19 +35,13 @@ export function SessionContextUsage() {
{(ctx) => (
<Tooltip
value={
<div class="flex flex-col gap-1">
<div class="flex gap-3">
<span class="opacity-70 text-right flex-1">Tokens</span>
<span class="text-left flex-1">{ctx().tokens}</span>
</div>
<div class="flex gap-3">
<span class="opacity-70 text-right flex-1">Usage</span>
<span class="text-left flex-1">{ctx().percentage ?? 0}%</span>
</div>
<div class="flex gap-3">
<span class="opacity-70 text-right flex-1">Cost</span>
<span class="text-left flex-1">{cost()}</span>
</div>
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
<span class="opacity-70 text-right">Tokens</span>
<span class="text-left">{ctx().tokens}</span>
<span class="opacity-70 text-right">Usage</span>
<span class="text-left">{ctx().percentage ?? 0}%</span>
<span class="opacity-70 text-right">Cost</span>
<span class="text-left">{cost()}</span>
</div>
}
placement="top"
@@ -0,0 +1,40 @@
import { createMemo, Show } from "solid-js"
import { Icon } from "@opencode-ai/ui/icon"
import { useSync } from "@/context/sync"
import { Tooltip } from "@opencode-ai/ui/tooltip"
export function SessionLspIndicator() {
const sync = useSync()
const lspStats = createMemo(() => {
const lsp = sync.data.lsp ?? []
const connected = lsp.filter((s) => s.status === "connected").length
const hasError = lsp.some((s) => s.status === "error")
const total = lsp.length
return { connected, hasError, total }
})
const tooltipContent = createMemo(() => {
const lsp = sync.data.lsp ?? []
if (lsp.length === 0) return "No LSP servers"
return lsp.map((s) => s.name).join(", ")
})
return (
<Show when={lspStats().total > 0}>
<Tooltip placement="top" value={tooltipContent()}>
<div class="flex items-center gap-1 px-2 cursor-default select-none">
<Icon
name="code"
size="small"
classList={{
"text-icon-critical-base": lspStats().hasError,
"text-icon-success-base": !lspStats().hasError && lspStats().connected > 0,
}}
/>
<span class="text-12-regular text-text-weak">{lspStats().connected} LSP</span>
</div>
</Tooltip>
</Show>
)
}
@@ -0,0 +1,36 @@
import { createMemo, Show } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useSync } from "@/context/sync"
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
export function SessionMcpIndicator() {
const sync = useSync()
const dialog = useDialog()
const mcpStats = createMemo(() => {
const mcp = sync.data.mcp ?? {}
const entries = Object.entries(mcp)
const enabled = entries.filter(([, status]) => status.status === "connected").length
const failed = entries.some(([, status]) => status.status === "failed")
const total = entries.length
return { enabled, failed, total }
})
return (
<Show when={mcpStats().total > 0}>
<Button variant="ghost" onClick={() => dialog.show(() => <DialogSelectMcp />)}>
<Icon
name="mcp"
size="small"
classList={{
"text-icon-critical-base": mcpStats().failed,
"text-icon-success-base": !mcpStats().failed && mcpStats().enabled > 0,
}}
/>
<span class="text-12-regular text-text-weak">{mcpStats().enabled} MCP</span>
</Button>
</Show>
)
}
@@ -0,0 +1,32 @@
import { createMemo, Show, type ParentProps } from "solid-js"
import { usePlatform } from "@/context/platform"
import { useSync } from "@/context/sync"
import { useGlobalSync } from "@/context/global-sync"
export function StatusBar(props: ParentProps) {
const platform = usePlatform()
const sync = useSync()
const globalSync = useGlobalSync()
const directoryDisplay = createMemo(() => {
const directory = sync.data.path.directory || ""
const home = globalSync.data.path.home || ""
const short = home && directory.startsWith(home) ? directory.replace(home, "~") : directory
const branch = sync.data.vcs?.branch
return branch ? `${short}:${branch}` : short
})
return (
<div class="h-8 w-full shrink-0 flex items-center justify-between px-2 border-t border-border-weak-base bg-background-base">
<div class="flex items-center gap-3">
<Show when={platform.version}>
<span class="text-12-regular text-text-weak">v{platform.version}</span>
</Show>
<Show when={directoryDisplay()}>
<span class="text-12-regular text-text-weak">{directoryDisplay()}</span>
</Show>
</div>
<div class="flex items-center">{props.children}</div>
</div>
)
}
+113 -31
View File
@@ -1,9 +1,9 @@
import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js"
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
import { useSDK } from "@/context/sdk"
import { SerializeAddon } from "@/addons/serialize"
import { LocalPTY } from "@/context/terminal"
import { usePrefersDark } from "@solid-primitives/media"
import { resolveThemeVariant, useTheme } from "@opencode-ai/ui/theme"
export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY
@@ -12,8 +12,28 @@ export interface TerminalProps extends ComponentProps<"div"> {
onConnectError?: (error: unknown) => void
}
type TerminalColors = {
background: string
foreground: string
cursor: string
}
const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
light: {
background: "#fcfcfc",
foreground: "#211e1e",
cursor: "#211e1e",
},
dark: {
background: "#191515",
foreground: "#d4d4d4",
cursor: "#d4d4d4",
},
}
export const Terminal = (props: TerminalProps) => {
const sdk = useSDK()
const theme = useTheme()
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
let ws: WebSocket
@@ -22,7 +42,64 @@ export const Terminal = (props: TerminalProps) => {
let serializeAddon: SerializeAddon
let fitAddon: FitAddon
let handleResize: () => void
const prefersDark = usePrefersDark()
const getTerminalColors = (): TerminalColors => {
const mode = theme.mode()
const fallback = DEFAULT_TERMINAL_COLORS[mode]
const currentTheme = theme.themes()[theme.themeId()]
if (!currentTheme) return fallback
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
if (!variant?.seeds) return fallback
const resolved = resolveThemeVariant(variant, mode === "dark")
const text = resolved["text-base"] ?? fallback.foreground
const background = resolved["background-stronger"] ?? fallback.background
return {
background,
foreground: text,
cursor: text,
}
}
const [terminalColors, setTerminalColors] = createSignal<TerminalColors>(getTerminalColors())
createEffect(() => {
const colors = getTerminalColors()
setTerminalColors(colors)
if (!term) return
const setOption = (term as unknown as { setOption?: (key: string, value: TerminalColors) => void }).setOption
if (!setOption) return
setOption("theme", colors)
})
const focusTerminal = () => term?.focus()
const copySelection = () => {
if (!term || !term.hasSelection()) return false
const selection = term.getSelection()
if (!selection) return false
const clipboard = navigator.clipboard
if (clipboard?.writeText) {
clipboard.writeText(selection).catch(() => {})
return true
}
if (!document.body) return false
const textarea = document.createElement("textarea")
textarea.value = selection
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
document.body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
document.body.removeChild(textarea)
return copied
}
const handlePointerDown = () => {
const activeElement = document.activeElement
if (activeElement instanceof HTMLElement && activeElement !== container) {
activeElement.blur()
}
focusTerminal()
}
onMount(async () => {
ghostty = await Ghostty.load()
@@ -33,23 +110,22 @@ export const Terminal = (props: TerminalProps) => {
fontSize: 14,
fontFamily: "IBM Plex Mono, monospace",
allowTransparency: true,
theme: prefersDark()
? {
background: "#191515",
foreground: "#d4d4d4",
cursor: "#d4d4d4",
}
: {
background: "#fcfcfc",
foreground: "#211e1e",
cursor: "#211e1e",
},
theme: terminalColors(),
scrollback: 10_000,
ghostty,
})
term.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase()
if (key === "c") {
const macCopy = event.metaKey && !event.ctrlKey && !event.altKey
const linuxCopy = event.ctrlKey && event.shiftKey && !event.metaKey
if ((macCopy || linuxCopy) && copySelection()) {
event.preventDefault()
return true
}
}
// allow for ctrl-` to toggle terminal in parent
if (event.ctrlKey && event.key.toLowerCase() === "`") {
if (event.ctrlKey && key === "`") {
event.preventDefault()
return true
}
@@ -62,6 +138,8 @@ export const Terminal = (props: TerminalProps) => {
term.loadAddon(fitAddon)
term.open(container)
container.addEventListener("pointerdown", handlePointerDown)
focusTerminal()
if (local.pty.buffer) {
if (local.pty.rows && local.pty.cols) {
@@ -75,20 +153,20 @@ export const Terminal = (props: TerminalProps) => {
fitAddon.fit()
}
container.focus()
fitAddon.observeResize()
handleResize = () => fitAddon.fit()
window.addEventListener("resize", handleResize)
term.onResize(async (size) => {
if (ws && ws.readyState === WebSocket.OPEN) {
await sdk.client.pty.update({
ptyID: local.pty.id,
size: {
cols: size.cols,
rows: size.rows,
},
})
await sdk.client.pty
.update({
ptyID: local.pty.id,
size: {
cols: size.cols,
rows: size.rows,
},
})
.catch(() => {})
}
})
term.onData((data) => {
@@ -106,13 +184,15 @@ export const Terminal = (props: TerminalProps) => {
// })
ws.addEventListener("open", () => {
console.log("WebSocket connected")
sdk.client.pty.update({
ptyID: local.pty.id,
size: {
cols: term.cols,
rows: term.rows,
},
})
sdk.client.pty
.update({
ptyID: local.pty.id,
size: {
cols: term.cols,
rows: term.rows,
},
})
.catch(() => {})
})
ws.addEventListener("message", (event) => {
term.write(event.data)
@@ -130,6 +210,7 @@ export const Terminal = (props: TerminalProps) => {
if (handleResize) {
window.removeEventListener("resize", handleResize)
}
container.removeEventListener("pointerdown", handlePointerDown)
if (serializeAddon && props.onCleanup) {
const buffer = serializeAddon.serialize()
props.onCleanup({
@@ -149,6 +230,7 @@ export const Terminal = (props: TerminalProps) => {
ref={container}
data-component="terminal"
data-prevent-autofocus
style={{ "background-color": terminalColors().background }}
classList={{
...(local.classList ?? {}),
"size-full px-6 py-3 font-mono": true,
+25 -6
View File
@@ -26,6 +26,7 @@ export interface CommandOption {
suggested?: boolean
disabled?: boolean
onSelect?: (source?: "palette" | "keybind" | "slash") => void
onHighlight?: () => (() => void) | void
}
export function parseKeybind(config: string): Keybind[] {
@@ -115,6 +116,28 @@ export function formatKeybind(config: string): string {
function DialogCommand(props: { options: CommandOption[] }) {
const dialog = useDialog()
let cleanup: (() => void) | void
let committed = false
const handleMove = (option: CommandOption | undefined) => {
cleanup?.()
cleanup = option?.onHighlight?.()
}
const handleSelect = (option: CommandOption | undefined) => {
if (option) {
committed = true
cleanup = undefined
dialog.close()
option.onSelect?.("palette")
}
}
onCleanup(() => {
if (!committed) {
cleanup?.()
}
})
return (
<Dialog title="Commands">
@@ -125,12 +148,8 @@ function DialogCommand(props: { options: CommandOption[] }) {
key={(x) => x?.id}
filterKeys={["title", "description", "category"]}
groupBy={(x) => x.category ?? ""}
onSelect={(option) => {
if (option) {
dialog.close()
option.onSelect?.("palette")
}
}}
onMove={handleMove}
onSelect={handleSelect}
>
{(option) => (
<div class="w-full flex items-center justify-between gap-4">
+103 -8
View File
@@ -12,6 +12,10 @@ import {
type ProviderListResponse,
type ProviderAuthResponse,
type Command,
type McpStatus,
type LspStatus,
type VcsInfo,
type Permission,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store"
@@ -19,7 +23,7 @@ import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { useGlobalSDK } from "./global-sdk"
import { ErrorPage, type InitError } from "../pages/error"
import { createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js"
import { batch, createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
@@ -41,6 +45,14 @@ type State = {
todo: {
[sessionID: string]: Todo[]
}
permission: {
[sessionID: string]: Permission[]
}
mcp: {
[name: string]: McpStatus
}
lsp: LspStatus[]
vcs: VcsInfo | undefined
limit: number
message: {
[sessionID: string]: Message[]
@@ -59,21 +71,19 @@ function createGlobalSync() {
project: Project[]
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
children: Record<string, State>
}>({
ready: false,
path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: [],
provider: { all: [], connected: [], default: {} },
provider_auth: {},
children: {},
})
const children: Record<string, ReturnType<typeof createStore<State>>> = {}
function child(directory: string) {
if (!directory) console.error("No directory provided")
if (!children[directory]) {
setGlobalStore("children", directory, {
children[directory] = createStore<State>({
project: "",
provider: { all: [], connected: [], default: {} },
config: {},
@@ -85,11 +95,14 @@ function createGlobalSync() {
session_status: {},
session_diff: {},
todo: {},
permission: {},
mcp: {},
lsp: [],
vcs: undefined,
limit: 5,
message: {},
part: {},
})
children[directory] = createStore(globalStore.children[directory])
bootstrapInstance(directory)
}
return children[directory]
@@ -111,7 +124,7 @@ function createGlobalSync() {
const updated = new Date(s.time.updated).getTime()
return updated > fourHoursAgo
})
setStore("session", sessions)
setStore("session", reconcile(sessions, { key: "id" }))
})
.catch((err) => {
console.error("Failed to load sessions", err)
@@ -122,7 +135,7 @@ function createGlobalSync() {
async function bootstrapInstance(directory: string) {
if (!directory) return
const [, setStore] = child(directory)
const [store, setStore] = child(directory)
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
directory,
@@ -149,6 +162,38 @@ function createGlobalSync() {
session: () => loadSessions(directory),
status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
mcp: () => sdk.mcp.status().then((x) => setStore("mcp", x.data ?? {})),
lsp: () => sdk.lsp.status().then((x) => setStore("lsp", x.data ?? [])),
vcs: () => sdk.vcs.get().then((x) => setStore("vcs", x.data)),
permission: () =>
sdk.permission.list().then((x) => {
const grouped: Record<string, Permission[]> = {}
for (const perm of x.data ?? []) {
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.slice().sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
})
}),
}
await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
.then(() => setStore("ready", true))
@@ -218,7 +263,7 @@ function createGlobalSync() {
setStore("session_diff", event.properties.sessionID, reconcile(event.properties.diff, { key: "file" }))
break
case "todo.updated":
setStore("todo", event.properties.sessionID, reconcile(event.properties.todos))
setStore("todo", event.properties.sessionID, reconcile(event.properties.todos, { key: "id" }))
break
case "session.status": {
setStore("session_status", event.properties.sessionID, reconcile(event.properties.status))
@@ -295,6 +340,56 @@ function createGlobalSync() {
}
break
}
case "vcs.branch.updated": {
setStore("vcs", { branch: event.properties.branch })
break
}
case "permission.updated": {
const sessionID = event.properties.sessionID
const permissions = store.permission[sessionID]
if (!permissions) {
setStore("permission", sessionID, [event.properties])
break
}
const result = Binary.search(permissions, event.properties.id, (p) => p.id)
if (result.found) {
setStore("permission", sessionID, result.index, reconcile(event.properties))
break
}
setStore(
"permission",
sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties)
}),
)
break
}
case "permission.replied": {
const permissions = store.permission[event.properties.sessionID]
if (!permissions) break
const result = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
if (!result.found) break
setStore(
"permission",
event.properties.sessionID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
break
}
case "lsp.updated": {
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
directory,
throwOnError: true,
})
sdk.lsp.status().then((x) => setStore("lsp", x.data ?? []))
break
}
}
})
+1
View File
@@ -70,6 +70,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
{
...project,
...(metadata ?? {}),
icon: { url: metadata?.icon?.url, color: metadata?.icon?.color },
},
]
}
+14 -11
View File
@@ -377,17 +377,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const list = async (path: string) => {
return sdk.client.file.list({ path: path + "/" }).then((x) => {
setStore(
"node",
produce((draft) => {
x.data!.forEach((node) => {
if (node.path in draft) return
draft[node.path] = node
})
}),
)
})
return sdk.client.file
.list({ path: path + "/" })
.then((x) => {
setStore(
"node",
produce((draft) => {
x.data!.forEach((node) => {
if (node.path in draft) return
draft[node.path] = node
})
}),
)
})
.catch(() => {})
}
const searchFiles = (query: string) => sdk.client.find.files({ query, dirs: "false" }).then((x) => x.data!)
+130
View File
@@ -0,0 +1,130 @@
import { createEffect, createRoot, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { Permission } from "@opencode-ai/sdk/v2/client"
import { persisted } from "@/utils/persist"
type PermissionsBySession = {
[sessionID: string]: Permission[]
}
type PermissionRespondFn = (input: {
sessionID: string
permissionID: string
response: "once" | "always" | "reject"
}) => void
const AUTO_ACCEPT_TYPES = new Set(["edit", "write"])
function shouldAutoAccept(perm: Permission) {
return AUTO_ACCEPT_TYPES.has(perm.type)
}
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
name: "Permission",
init: (props: { permissions: PermissionsBySession; onRespond: PermissionRespondFn }) => {
const [store, setStore, _, ready] = persisted(
"permission.v1",
createStore({
autoAcceptEdits: {} as Record<string, boolean>,
}),
)
const responded = new Set<string>()
const watches = new Map<string, () => void>()
function respond(perm: Permission) {
if (responded.has(perm.id)) return
responded.add(perm.id)
props.onRespond({
sessionID: perm.sessionID,
permissionID: perm.id,
response: "once",
})
}
function watch(sessionID: string) {
if (watches.has(sessionID)) return
const dispose = createRoot((dispose) => {
createEffect(() => {
if (!store.autoAcceptEdits[sessionID]) return
const permissions = props.permissions[sessionID] ?? []
permissions.length
for (const perm of permissions) {
if (!shouldAutoAccept(perm)) continue
respond(perm)
}
})
return dispose
})
watches.set(sessionID, dispose)
}
function unwatch(sessionID: string) {
const dispose = watches.get(sessionID)
if (!dispose) return
dispose()
watches.delete(sessionID)
}
createEffect(() => {
if (!ready()) return
for (const sessionID in store.autoAcceptEdits) {
if (!store.autoAcceptEdits[sessionID]) continue
watch(sessionID)
}
})
onCleanup(() => {
for (const dispose of watches.values()) dispose()
watches.clear()
})
function enable(sessionID: string) {
setStore("autoAcceptEdits", sessionID, true)
watch(sessionID)
const permissions = props.permissions[sessionID] ?? []
for (const perm of permissions) {
if (!shouldAutoAccept(perm)) continue
respond(perm)
}
}
function disable(sessionID: string) {
setStore("autoAcceptEdits", sessionID, false)
unwatch(sessionID)
}
return {
get permissions() {
return props.permissions
},
respond: props.onRespond,
isAutoAccepting(sessionID: string) {
return store.autoAcceptEdits[sessionID] ?? false
},
toggleAutoAccept(sessionID: string) {
if (store.autoAcceptEdits[sessionID]) {
disable(sessionID)
return
}
enable(sessionID)
},
enableAutoAccept(sessionID: string) {
if (store.autoAcceptEdits[sessionID]) return
enable(sessionID)
},
disableAutoAccept(sessionID: string) {
disable(sessionID)
},
}
},
})
+44 -20
View File
@@ -1,5 +1,5 @@
import { produce } from "solid-js/store"
import { createMemo } from "solid-js"
import { batch, createMemo } from "solid-js"
import { produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { createSimpleContext } from "@opencode-ai/ui/context"
@@ -56,7 +56,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const result = Binary.search(messages, input.messageID, (m) => m.id)
messages.splice(result.index, 0, message)
}
draft.part[input.messageID] = input.parts.slice()
draft.part[input.messageID] = input.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
}),
)
},
@@ -67,22 +67,46 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
retry(() => sdk.client.session.todo({ sessionID })),
retry(() => sdk.client.session.diff({ sessionID })),
])
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
if (match.found) draft.session[match.index] = session.data!
if (!match.found) draft.session.splice(match.index, 0, session.data!)
draft.todo[sessionID] = todo.data ?? []
draft.message[sessionID] = messages
.data!.map((x) => x.info)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
for (const message of messages.data!) {
draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
}
draft.session_diff[sessionID] = diff.data ?? []
}),
)
batch(() => {
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = session.data!
return
}
draft.splice(match.index, 0, session.data!)
}),
)
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
setStore(
"message",
sessionID,
reconcile(
(messages.data ?? [])
.map((x) => x.info)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
for (const message of messages.data ?? []) {
setStore(
"part",
message.info.id,
reconcile(
message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
})
},
fetch: async (count = 10) => {
setStore("limit", (x) => x + count)
@@ -91,7 +115,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
.slice(0, store.limit)
setStore("session", sessions)
setStore("session", reconcile(sessions, { key: "id" }))
})
},
more: createMemo(() => store.session.length >= store.limit),
+38 -22
View File
@@ -36,35 +36,49 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
all: createMemo(() => Object.values(store.all)),
active: createMemo(() => store.active),
new() {
sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => {
const id = pty.data?.id
if (!id) return
setStore("all", [
...store.all,
{
id,
title: pty.data?.title ?? "Terminal",
},
])
setStore("active", id)
})
sdk.client.pty
.create({ title: `Terminal ${store.all.length + 1}` })
.then((pty) => {
const id = pty.data?.id
if (!id) return
setStore("all", [
...store.all,
{
id,
title: pty.data?.title ?? "Terminal",
},
])
setStore("active", id)
})
.catch((e) => {
console.error("Failed to create terminal", e)
})
},
update(pty: Partial<LocalPTY> & { id: string }) {
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
sdk.client.pty.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
sdk.client.pty
.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
.catch((e) => {
console.error("Failed to update terminal", e)
})
},
async clone(id: string) {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
const clone = await sdk.client.pty.create({
title: pty.title,
})
if (!clone.data) return
const clone = await sdk.client.pty
.create({
title: pty.title,
})
.catch((e) => {
console.error("Failed to clone terminal", e)
return undefined
})
if (!clone?.data) return
setStore("all", index, {
...pty,
...clone.data,
@@ -88,7 +102,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
setStore("active", previous?.id)
}
})
await sdk.client.pty.remove({ ptyID: id })
await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
console.error("Failed to close terminal", e)
})
},
move(id: string, to: number) {
const index = store.all.findIndex((f) => f.id === id)
+14 -4
View File
@@ -1,8 +1,9 @@
import { createMemo, Show, type ParentProps } from "solid-js"
import { useParams } from "@solidjs/router"
import { SDKProvider } from "@/context/sdk"
import { SDKProvider, useSDK } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local"
import { PermissionProvider } from "@/context/permission"
import { base64Decode } from "@opencode-ai/util/encode"
import { DataProvider } from "@opencode-ai/ui/context"
import { iife } from "@opencode-ai/util/iife"
@@ -18,10 +19,19 @@ export default function Layout(props: ParentProps) {
<SyncProvider>
{iife(() => {
const sync = useSync()
const sdk = useSDK()
const respond = (input: {
sessionID: string
permissionID: string
response: "once" | "always" | "reject"
}) => sdk.client.permission.respond(input)
return (
<DataProvider data={sync.data} directory={directory()}>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
<PermissionProvider permissions={sync.data.permission} onRespond={respond}>
<DataProvider data={sync.data} directory={directory()} onPermissionRespond={respond}>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
</PermissionProvider>
)
})}
</SyncProvider>
+83 -20
View File
@@ -20,11 +20,51 @@ function isInitError(error: unknown): error is InitError {
)
}
function safeJson(value: unknown): string {
const seen = new WeakSet<object>()
const json = JSON.stringify(
value,
(_key, val) => {
if (typeof val === "bigint") return val.toString()
if (typeof val === "object" && val) {
if (seen.has(val)) return "[Circular]"
seen.add(val)
}
return val
},
2,
)
return json ?? String(value)
}
function formatInitError(error: InitError): string {
const data = error.data
switch (error.name) {
case "MCPFailed":
return `MCP server "${data.name}" failed. Note, opencode does not support MCP authentication yet.`
case "ProviderAuthError": {
const providerID = typeof data.providerID === "string" ? data.providerID : "unknown"
const message = typeof data.message === "string" ? data.message : safeJson(data.message)
return `Provider authentication failed (${providerID}): ${message}`
}
case "APIError": {
const message = typeof data.message === "string" ? data.message : "API error"
const lines: string[] = [message]
if (typeof data.statusCode === "number") {
lines.push(`Status: ${data.statusCode}`)
}
if (typeof data.isRetryable === "boolean") {
lines.push(`Retryable: ${data.isRetryable}`)
}
if (typeof data.responseBody === "string" && data.responseBody) {
lines.push(`Response body:\n${data.responseBody}`)
}
return lines.join("\n")
}
case "ProviderModelNotFoundError": {
const { providerID, modelID, suggestions } = data as {
providerID: string
@@ -37,10 +77,14 @@ function formatInitError(error: InitError): string {
`Check your config (opencode.json) provider/model names`,
].join("\n")
}
case "ProviderInitError":
return `Failed to initialize provider "${data.providerID}". Check credentials and configuration.`
case "ConfigJsonError":
return `Config file at ${data.path} is not valid JSON(C)` + (data.message ? `: ${data.message}` : "")
case "ProviderInitError": {
const providerID = typeof data.providerID === "string" ? data.providerID : "unknown"
return `Failed to initialize provider "${providerID}". Check credentials and configuration.`
}
case "ConfigJsonError": {
const message = typeof data.message === "string" ? data.message : ""
return `Config file at ${data.path} is not valid JSON(C)` + (message ? `: ${message}` : "")
}
case "ConfigDirectoryTypoError":
return `Directory "${data.dir}" in ${data.path} is not valid. Rename the directory to "${data.suggestion}" or remove it. This is a common typo.`
case "ConfigFrontmatterError":
@@ -51,14 +95,14 @@ function formatInitError(error: InitError): string {
(issue: { message: string; path: string[] }) => "↳ " + issue.message + " " + issue.path.join("."),
)
: []
return [`Config file at ${data.path} is invalid` + (data.message ? `: ${data.message}` : ""), ...issues].join(
"\n",
)
const message = typeof data.message === "string" ? data.message : ""
return [`Config file at ${data.path} is invalid` + (message ? `: ${message}` : ""), ...issues].join("\n")
}
case "UnknownError":
return String(data.message)
return typeof data.message === "string" ? data.message : safeJson(data)
default:
return data.message ? String(data.message) : JSON.stringify(data, null, 2)
if (typeof data.message === "string") return data.message
return safeJson(data)
}
}
@@ -69,7 +113,7 @@ function formatErrorChain(error: unknown, depth = 0, parentMessage?: string): st
const message = formatInitError(error)
if (depth > 0 && parentMessage === message) return ""
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
return indent + message
return indent + `${error.name}\n${message}`
}
if (error instanceof Error) {
@@ -77,15 +121,34 @@ function formatErrorChain(error: unknown, depth = 0, parentMessage?: string): st
const parts: string[] = []
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
if (!isDuplicate) {
// Stack already includes error name and message, so prefer it
parts.push(indent + (error.stack ?? `${error.name}: ${error.message}`))
} else if (error.stack) {
// Duplicate message - only show the stack trace lines (skip message)
const trace = error.stack.split("\n").slice(1).join("\n").trim()
if (trace) {
parts.push(trace)
const header = `${error.name}${error.message ? `: ${error.message}` : ""}`
const stack = error.stack?.trim()
if (stack) {
const startsWithHeader = stack.startsWith(header)
if (isDuplicate && startsWithHeader) {
const trace = stack.split("\n").slice(1).join("\n").trim()
if (trace) {
parts.push(indent + trace)
}
}
if (isDuplicate && !startsWithHeader) {
parts.push(indent + stack)
}
if (!isDuplicate && startsWithHeader) {
parts.push(indent + stack)
}
if (!isDuplicate && !startsWithHeader) {
parts.push(indent + `${header}\n${stack}`)
}
}
if (!stack && !isDuplicate) {
parts.push(indent + header)
}
if (error.cause) {
@@ -105,7 +168,7 @@ function formatErrorChain(error: unknown, depth = 0, parentMessage?: string): st
}
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
return indent + JSON.stringify(error, null, 2)
return indent + safeJson(error)
}
function formatError(error: unknown): string {
@@ -131,7 +194,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
readOnly
copyable
multiline
class="max-h-96 w-full font-mono text-xs no-scrollbar whitespace-pre"
class="max-h-96 w-full font-mono text-xs no-scrollbar"
label="Error Details"
hideLabel
/>
+225 -58
View File
@@ -41,14 +41,16 @@ import {
} from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useProviders } from "@/hooks/use-providers"
import { showToast, Toast } from "@opencode-ai/ui/toast"
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "@/context/global-sdk"
import { useNotification } from "@/context/notification"
import { Binary } from "@opencode-ai/util/binary"
import { Header } from "@/components/header"
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 { useCommand } from "@/context/command"
import { DialogEditProject } from "@/components/dialog-edit-project"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
export default function Layout(props: ParentProps) {
@@ -89,6 +91,41 @@ export default function Layout(props: ParentProps) {
const providers = useProviders()
const dialog = useDialog()
const command = useCommand()
const theme = useTheme()
const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
const colorSchemeLabel: Record<ColorScheme, string> = {
system: "System",
light: "Light",
dark: "Dark",
}
function cycleTheme(direction = 1) {
const ids = availableThemeEntries().map(([id]) => id)
if (ids.length === 0) return
const currentIndex = ids.indexOf(theme.themeId())
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
const nextThemeId = ids[nextIndex]
theme.setTheme(nextThemeId)
const nextTheme = theme.themes()[nextThemeId]
showToast({
title: "Theme switched",
description: nextTheme?.name ?? nextThemeId,
})
}
function cycleColorScheme(direction = 1) {
const current = theme.colorScheme()
const currentIndex = colorSchemeOrder.indexOf(current)
const nextIndex =
currentIndex === -1 ? 0 : (currentIndex + direction + colorSchemeOrder.length) % colorSchemeOrder.length
const next = colorSchemeOrder[nextIndex]
theme.setColorScheme(next)
showToast({
title: "Color scheme",
description: colorSchemeLabel[next],
})
}
onMount(async () => {
if (platform.checkUpdate && platform.update && platform.restart) {
@@ -117,6 +154,71 @@ export default function Layout(props: ParentProps) {
}
})
onMount(() => {
const seenSessions = new Set<string>()
const toastBySession = new Map<string, number>()
const unsub = globalSDK.event.listen((e) => {
if (e.details?.type !== "permission.updated") return
const directory = e.name
const permission = e.details.properties
const sessionKey = `${directory}:${permission.sessionID}`
if (seenSessions.has(sessionKey)) return
seenSessions.add(sessionKey)
const currentDir = params.dir ? base64Decode(params.dir) : undefined
const currentSession = params.id
if (directory === currentDir && permission.sessionID === currentSession) return
const [store] = globalSync.child(directory)
const session = store.session.find((s) => s.id === permission.sessionID)
if (directory === currentDir && session?.parentID === currentSession) return
const sessionTitle = session?.title ?? "New session"
const projectName = getFilename(directory)
const toastId = showToast({
persistent: true,
icon: "checklist",
title: "Permission required",
description: `${sessionTitle} in ${projectName} needs permission`,
actions: [
{
label: "Go to session",
onClick: () => {
navigate(`/${base64Encode(directory)}/session/${permission.sessionID}`)
},
},
{
label: "Dismiss",
onClick: "dismiss",
},
],
})
toastBySession.set(sessionKey, toastId)
})
onCleanup(unsub)
createEffect(() => {
const currentDir = params.dir ? base64Decode(params.dir) : undefined
const currentSession = params.id
if (!currentDir || !currentSession) return
const sessionKey = `${currentDir}:${currentSession}`
const toastId = toastBySession.get(sessionKey)
if (toastId !== undefined) {
toaster.dismiss(toastId)
toastBySession.delete(sessionKey)
seenSessions.delete(sessionKey)
}
const [store] = globalSync.child(currentDir)
const childSessions = store.session.filter((s) => s.parentID === currentSession)
for (const child of childSessions) {
const childKey = `${currentDir}:${child.id}`
const childToastId = toastBySession.get(childKey)
if (childToastId !== undefined) {
toaster.dismiss(childToastId)
toastBySession.delete(childKey)
seenSessions.delete(childKey)
}
}
})
})
function sortSessions(a: Session, b: Session) {
const now = Date.now()
const oneMinuteAgo = now - 60 * 1000
@@ -221,57 +323,102 @@ export default function Layout(props: ParentProps) {
}
}
command.register(() => [
{
id: "sidebar.toggle",
title: "Toggle sidebar",
category: "View",
keybind: "mod+b",
onSelect: () => layout.sidebar.toggle(),
},
...(platform.openDirectoryPickerDialog
? [
{
id: "project.open",
title: "Open project",
category: "Project",
keybind: "mod+o",
onSelect: () => chooseProject(),
},
]
: []),
{
id: "provider.connect",
title: "Connect provider",
category: "Provider",
onSelect: () => connectProvider(),
},
{
id: "session.previous",
title: "Previous session",
category: "Session",
keybind: "alt+arrowup",
onSelect: () => navigateSessionByOffset(-1),
},
{
id: "session.next",
title: "Next session",
category: "Session",
keybind: "alt+arrowdown",
onSelect: () => navigateSessionByOffset(1),
},
{
id: "session.archive",
title: "Archive session",
category: "Session",
keybind: "mod+shift+backspace",
disabled: !params.dir || !params.id,
onSelect: () => {
const session = currentSessions().find((s) => s.id === params.id)
if (session) archiveSession(session)
command.register(() => {
const commands: CommandOption[] = [
{
id: "sidebar.toggle",
title: "Toggle sidebar",
category: "View",
keybind: "mod+b",
onSelect: () => layout.sidebar.toggle(),
},
},
])
...(platform.openDirectoryPickerDialog
? [
{
id: "project.open",
title: "Open project",
category: "Project",
keybind: "mod+o",
onSelect: () => chooseProject(),
},
]
: []),
{
id: "provider.connect",
title: "Connect provider",
category: "Provider",
onSelect: () => connectProvider(),
},
{
id: "session.previous",
title: "Previous session",
category: "Session",
keybind: "alt+arrowup",
onSelect: () => navigateSessionByOffset(-1),
},
{
id: "session.next",
title: "Next session",
category: "Session",
keybind: "alt+arrowdown",
onSelect: () => navigateSessionByOffset(1),
},
{
id: "session.archive",
title: "Archive session",
category: "Session",
keybind: "mod+shift+backspace",
disabled: !params.dir || !params.id,
onSelect: () => {
const session = currentSessions().find((s) => s.id === params.id)
if (session) archiveSession(session)
},
},
{
id: "theme.cycle",
title: "Cycle theme",
category: "Theme",
keybind: "mod+shift+t",
onSelect: () => cycleTheme(1),
},
]
for (const [id, definition] of availableThemeEntries()) {
commands.push({
id: `theme.set.${id}`,
title: `Use theme: ${definition.name ?? id}`,
category: "Theme",
onSelect: () => theme.commitPreview(),
onHighlight: () => {
theme.previewTheme(id)
return () => theme.cancelPreview()
},
})
}
commands.push({
id: "theme.scheme.cycle",
title: "Cycle color scheme",
category: "Theme",
keybind: "mod+shift+s",
onSelect: () => cycleColorScheme(1),
})
for (const scheme of colorSchemeOrder) {
commands.push({
id: `theme.scheme.${scheme}`,
title: `Use color scheme: ${colorSchemeLabel[scheme]}`,
category: "Theme",
onSelect: () => theme.commitPreview(),
onHighlight: () => {
theme.previewColorScheme(scheme)
return () => theme.cancelPreview()
},
})
}
return commands
})
function connectProvider() {
dialog.show(() => <DialogSelectProvider />)
@@ -376,7 +523,7 @@ export default function Layout(props: ParentProps) {
const notification = useNotification()
const notifications = createMemo(() => notification.project.unseen(props.project.worktree))
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
const name = createMemo(() => getFilename(props.project.worktree))
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)"
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
@@ -412,7 +559,7 @@ export default function Layout(props: ParentProps) {
}
const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => {
const name = createMemo(() => getFilename(props.project.worktree))
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
const current = createMemo(() => base64Decode(params.dir ?? ""))
return (
<Switch>
@@ -454,8 +601,20 @@ export default function Layout(props: ParentProps) {
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
const notifications = createMemo(() => notification.session.unseen(props.session.id))
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
const hasPermissions = createMemo(() => {
const store = globalSync.child(props.project.worktree)[0]
const permissions = store.permission?.[props.session.id] ?? []
if (permissions.length > 0) return true
const childSessions = store.session.filter((s) => s.parentID === props.session.id)
for (const child of childSessions) {
const childPermissions = store.permission?.[child.id] ?? []
if (childPermissions.length > 0) return true
}
return false
})
const isWorking = createMemo(() => {
if (props.session.id === params.id) return false
if (hasPermissions()) return false
const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id]
return status?.type === "busy" || status?.type === "retry"
})
@@ -486,6 +645,9 @@ export default function Layout(props: ParentProps) {
<Match when={isWorking()}>
<Spinner class="size-2.5 mr-0.5" />
</Match>
<Match when={hasPermissions()}>
<div class="size-1.5 mr-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={hasError()}>
<div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
@@ -540,7 +702,7 @@ export default function Layout(props: ParentProps) {
const sortable = createSortable(props.project.worktree)
const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened())
const slug = createMemo(() => base64Encode(props.project.worktree))
const name = createMemo(() => getFilename(props.project.worktree))
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
const [store, setProjectStore] = globalSync.child(props.project.worktree)
const sessions = createMemo(() => store.session.toSorted(sortSessions))
const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
@@ -586,8 +748,13 @@ export default function Layout(props: ParentProps) {
<DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" />
<DropdownMenu.Portal>
<DropdownMenu.Content>
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogEditProject project={props.project} />)}
>
<DropdownMenu.ItemLabel>Edit project</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => closeProject(props.project.worktree)}>
<DropdownMenu.ItemLabel>Close Project</DropdownMenu.ItemLabel>
<DropdownMenu.ItemLabel>Close project</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
@@ -749,7 +916,7 @@ export default function Layout(props: ParentProps) {
</div>
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
<Switch>
<Match when={!providers.paid().length && expanded()}>
<Match when={providers.all().length > 0 && !providers.paid().length && expanded()}>
<div class="rounded-md bg-background-stronger shadow-xs-border-base">
<div class="p-3 flex flex-col gap-2">
<div class="text-12-medium text-text-strong">Getting started</div>
@@ -768,7 +935,7 @@ export default function Layout(props: ParentProps) {
</Tooltip>
</div>
</Match>
<Match when={true}>
<Match when={providers.all().length > 0}>
<Tooltip placement="right" value="Connect provider" inactive={expanded()}>
<Button
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
+101 -19
View File
@@ -49,6 +49,7 @@ import { checksum } from "@opencode-ai/util/encode"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
import { DialogSelectModel } from "@/components/dialog-select-model"
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
import { useCommand } from "@/context/command"
import { useNavigate, useParams } from "@solidjs/router"
import { UserMessage } from "@opencode-ai/sdk/v2"
@@ -56,6 +57,17 @@ import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt"
import { extractPromptFromParts } from "@/utils/prompt"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { StatusBar } from "@/components/status-bar"
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
import { usePermission } from "@/context/permission"
import { showToast } from "@opencode-ai/ui/toast"
function same<T>(a: readonly T[], b: readonly T[]) {
if (a === b) return true
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}
export default function Page() {
const layout = useLayout()
@@ -70,22 +82,40 @@ export default function Page() {
const sdk = useSDK()
const prompt = usePrompt()
const permission = usePermission()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const revertMessageID = createMemo(() => info()?.revert?.messageID)
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const userMessages = createMemo(() =>
messages()
.filter((m) => m.role === "user")
.sort((a, b) => a.id.localeCompare(b.id)),
const emptyUserMessages: UserMessage[] = []
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
emptyUserMessages,
{ equals: same },
)
const visibleUserMessages = createMemo(
() => {
const revert = revertMessageID()
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
},
emptyUserMessages,
{ equals: same },
)
const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
createEffect(
on(
() => lastUserMessage()?.id,
() => {
const msg = lastUserMessage()
if (!msg) return
if (msg.agent) local.agent.set(msg.agent)
if (msg.model) local.model.set(msg.model)
},
),
)
const visibleUserMessages = createMemo(() => {
const revert = revertMessageID()
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
})
const lastUserMessage = createMemo(() => visibleUserMessages()?.at(-1))
const [store, setStore] = createStore({
clickTimer: undefined as number | undefined,
@@ -155,16 +185,37 @@ export default function Page() {
),
)
createEffect(() => {
params.id
const status = sync.data.session_status[params.id ?? ""] ?? { type: "idle" }
batch(() => {
setStore("userInteracted", false)
setStore("stepsExpanded", status.type !== "idle")
})
})
const idle = { type: "idle" as const }
createEffect(
on(
() => params.id,
(id) => {
const status = sync.data.session_status[id ?? ""] ?? idle
batch(() => {
setStore("userInteracted", false)
setStore("stepsExpanded", status.type !== "idle")
})
},
),
)
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
createEffect(
on(
() => status().type,
(type) => {
if (type !== "idle") return
batch(() => {
setStore("userInteracted", false)
setStore("stepsExpanded", false)
})
},
{ defer: true },
),
)
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? { type: "idle" })
const working = createMemo(() => status().type !== "idle" && activeMessage()?.id === lastUserMessage()?.id)
createRenderEffect((prev) => {
@@ -274,6 +325,15 @@ export default function Page() {
slash: "model",
onSelect: () => dialog.show(() => <DialogSelectModel />),
},
{
id: "mcp.toggle",
title: "Toggle MCPs",
description: "Toggle MCPs",
category: "MCP",
keybind: "mod+;",
slash: "mcp",
onSelect: () => dialog.show(() => <DialogSelectMcp />),
},
{
id: "agent.cycle",
title: "Cycle agent",
@@ -291,6 +351,22 @@ export default function Page() {
keybind: "shift+mod+.",
onSelect: () => local.agent.move(-1),
},
{
id: "permissions.autoaccept",
title: params.id && permission.isAutoAccepting(params.id) ? "Stop auto-accepting edits" : "Auto-accept edits",
category: "Permissions",
disabled: !params.id,
onSelect: () => {
if (!params.id) return
permission.toggleAutoAccept(params.id)
showToast({
title: permission.isAutoAccepting(params.id) ? "Auto-accepting edits" : "Stopped auto-accepting edits",
description: permission.isAutoAccepting(params.id)
? "Edit and write permissions will be automatically approved"
: "Edit and write permissions will require approval",
})
},
},
{
id: "session.undo",
title: "Undo",
@@ -562,6 +638,7 @@ export default function Page() {
<SessionTurn
sessionID={params.id!}
messageID={message.id}
lastUserMessageID={lastUserMessage()?.id}
stepsExpanded={store.mobileStepsExpanded[message.id] ?? false}
onStepsExpandedToggle={() => setStore("mobileStepsExpanded", message.id, (x) => !x)}
onUserInteracted={() => setStore("userInteracted", true)}
@@ -618,6 +695,7 @@ export default function Page() {
<SessionTurn
sessionID={params.id!}
messageID={activeMessage()!.id}
lastUserMessageID={lastUserMessage()?.id}
stepsExpanded={store.stepsExpanded}
onStepsExpandedToggle={() => setStore("stepsExpanded", (x) => !x)}
onUserInteracted={() => setStore("userInteracted", true)}
@@ -921,6 +999,10 @@ export default function Page() {
</DragDropProvider>
</div>
</Show>
<StatusBar>
<SessionLspIndicator />
<SessionMcpIndicator />
</StatusBar>
</div>
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.0.202",
"version": "1.0.209",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
@@ -5,28 +5,36 @@ import { useAuthSession } from "~/context/auth.session"
export async function GET(input: APIEvent) {
const url = new URL(input.request.url)
const code = url.searchParams.get("code")
if (!code) throw new Error("No code found")
const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`)
if (result.err) {
throw new Error(result.err.message)
}
const decoded = AuthClient.decode(result.tokens.access, {} as any)
if (decoded.err) throw new Error(decoded.err.message)
const session = await useAuthSession()
const id = decoded.subject.properties.accountID
await session.update((value) => {
return {
...value,
account: {
...value.account,
[id]: {
id,
email: decoded.subject.properties.email,
try {
const code = url.searchParams.get("code")
if (!code) throw new Error("No code found")
const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`)
if (result.err) throw new Error(result.err.message)
const decoded = AuthClient.decode(result.tokens.access, {} as any)
if (decoded.err) throw new Error(decoded.err.message)
const session = await useAuthSession()
const id = decoded.subject.properties.accountID
await session.update((value) => {
return {
...value,
account: {
...value.account,
[id]: {
id,
email: decoded.subject.properties.email,
},
},
},
current: id,
}
})
return redirect("/auth")
current: id,
}
})
return redirect("/auth")
} catch (e: any) {
return new Response(
JSON.stringify({
error: e.message,
cause: Object.fromEntries(url.searchParams.entries()),
}),
{ status: 500 },
)
}
}
@@ -0,0 +1,365 @@
import { Title } from "@solidjs/meta"
import { createAsync, query, useParams } from "@solidjs/router"
import { createSignal, For, Show } from "solid-js"
import { Database, desc, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
interface TaskSource {
repo: string
from: string
to: string
}
interface Judge {
score: number
rationale: string
judge: string
}
interface ScoreDetail {
criterion: string
weight: number
average: number
variance?: number
judges?: Judge[]
}
interface RunUsage {
input: number
output: number
cost: number
}
interface Run {
task: string
model: string
agent: string
score: {
final: number
base: number
penalty: number
}
scoreDetails: ScoreDetail[]
usage?: RunUsage
duration?: number
}
interface Prompt {
commit: string
prompt: string
}
interface AverageUsage {
input: number
output: number
cost: number
}
interface Task {
averageScore: number
averageDuration?: number
averageUsage?: AverageUsage
model?: string
agent?: string
summary?: string
runs?: Run[]
task: {
id: string
source: TaskSource
prompts?: Prompt[]
}
}
interface BenchmarkResult {
averageScore: number
tasks: Task[]
}
async function getTaskDetail(benchmarkId: string, taskId: string) {
"use server"
const rows = await Database.use((tx) =>
tx.select().from(BenchmarkTable).where(eq(BenchmarkTable.id, benchmarkId)).limit(1),
)
if (!rows[0]) return null
const parsed = JSON.parse(rows[0].result) as BenchmarkResult
const task = parsed.tasks.find((t) => t.task.id === taskId)
return task ?? null
}
const queryTaskDetail = query(getTaskDetail, "benchmark.task.detail")
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
if (minutes > 0) {
return `${minutes}m ${remainingSeconds}s`
}
return `${remainingSeconds}s`
}
export default function BenchDetail() {
const params = useParams()
const [benchmarkId, taskId] = (params.id ?? "").split(":")
const task = createAsync(() => queryTaskDetail(benchmarkId, taskId))
return (
<main data-page="bench-detail">
<Title>Benchmark - {taskId}</Title>
<div style={{ padding: "1rem" }}>
<Show when={task()} fallback={<p>Task not found</p>}>
<div style={{ "margin-bottom": "1rem" }}>
<div>
<strong>Agent: </strong>
{task()?.agent ?? "N/A"}
</div>
<div>
<strong>Model: </strong>
{task()?.model ?? "N/A"}
</div>
<div>
<strong>Task: </strong>
{task()!.task.id}
</div>
</div>
<div style={{ "margin-bottom": "1rem" }}>
<div>
<strong>Repo: </strong>
<a
href={`https://github.com/${task()!.task.source.repo}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: "#0066cc" }}
>
{task()!.task.source.repo}
</a>
</div>
<div>
<strong>From: </strong>
<a
href={`https://github.com/${task()!.task.source.repo}/commit/${task()!.task.source.from}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: "#0066cc" }}
>
{task()!.task.source.from.slice(0, 7)}
</a>
</div>
<div>
<strong>To: </strong>
<a
href={`https://github.com/${task()!.task.source.repo}/commit/${task()!.task.source.to}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: "#0066cc" }}
>
{task()!.task.source.to.slice(0, 7)}
</a>
</div>
</div>
<Show when={task()?.task.prompts && task()!.task.prompts!.length > 0}>
<div style={{ "margin-bottom": "1rem" }}>
<strong>Prompt:</strong>
<For each={task()!.task.prompts}>
{(p) => (
<div style={{ "margin-top": "0.5rem" }}>
<div style={{ "font-size": "0.875rem", color: "#666" }}>Commit: {p.commit.slice(0, 7)}</div>
<p style={{ "margin-top": "0.25rem", "white-space": "pre-wrap" }}>{p.prompt}</p>
</div>
)}
</For>
</div>
</Show>
<hr style={{ margin: "1rem 0", border: "none", "border-top": "1px solid #ccc" }} />
<div style={{ "margin-bottom": "1rem" }}>
<div>
<strong>Average Duration: </strong>
{task()?.averageDuration ? formatDuration(task()!.averageDuration!) : "N/A"}
</div>
<div>
<strong>Average Score: </strong>
{task()?.averageScore?.toFixed(3) ?? "N/A"}
</div>
<div>
<strong>Average Cost: </strong>
{task()?.averageUsage?.cost ? `$${task()!.averageUsage!.cost.toFixed(4)}` : "N/A"}
</div>
</div>
<Show when={task()?.summary}>
<div style={{ "margin-bottom": "1rem" }}>
<strong>Summary:</strong>
<p style={{ "margin-top": "0.5rem", "white-space": "pre-wrap" }}>{task()!.summary}</p>
</div>
</Show>
<Show when={task()?.runs && task()!.runs!.length > 0}>
<div style={{ "margin-bottom": "1rem" }}>
<strong>Runs:</strong>
<table style={{ "margin-top": "0.5rem", "border-collapse": "collapse", width: "100%" }}>
<thead>
<tr>
<th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>Run</th>
<th
style={{
border: "1px solid #ccc",
padding: "0.5rem",
"text-align": "left",
"white-space": "nowrap",
}}
>
Score (Base - Penalty)
</th>
<th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>Cost</th>
<th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>Duration</th>
<For each={task()!.runs![0]?.scoreDetails}>
{(detail) => (
<th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>
{detail.criterion} ({detail.weight})
</th>
)}
</For>
</tr>
</thead>
<tbody>
<For each={task()!.runs}>
{(run, index) => (
<tr>
<td style={{ border: "1px solid #ccc", padding: "0.5rem" }}>{index() + 1}</td>
<td style={{ border: "1px solid #ccc", padding: "0.5rem", "white-space": "nowrap" }}>
{run.score.final.toFixed(3)} ({run.score.base.toFixed(3)} - {run.score.penalty.toFixed(3)})
</td>
<td style={{ border: "1px solid #ccc", padding: "0.5rem" }}>
{run.usage?.cost ? `$${run.usage.cost.toFixed(4)}` : "N/A"}
</td>
<td style={{ border: "1px solid #ccc", padding: "0.5rem" }}>
{run.duration ? formatDuration(run.duration) : "N/A"}
</td>
<For each={run.scoreDetails}>
{(detail) => (
<td style={{ border: "1px solid #ccc", padding: "0.5rem" }}>
<For each={detail.judges}>
{(judge) => (
<span
style={{
color: judge.score === 1 ? "green" : judge.score === 0 ? "red" : "inherit",
"margin-right": "0.25rem",
}}
>
{judge.score === 1 ? "✓" : judge.score === 0 ? "✗" : judge.score}
</span>
)}
</For>
</td>
)}
</For>
</tr>
)}
</For>
</tbody>
</table>
<For each={task()!.runs}>
{(run, index) => (
<div style={{ "margin-top": "1rem" }}>
<h3 style={{ margin: "0 0 0.5rem 0" }}>Run {index() + 1}</h3>
<div>
<strong>Score: </strong>
{run.score.final.toFixed(3)} (Base: {run.score.base.toFixed(3)} - Penalty:{" "}
{run.score.penalty.toFixed(3)})
</div>
<For each={run.scoreDetails}>
{(detail) => (
<div style={{ "margin-top": "1rem", "padding-left": "1rem", "border-left": "2px solid #ccc" }}>
<div>
{detail.criterion} (weight: {detail.weight}){" "}
<For each={detail.judges}>
{(judge) => (
<span
style={{
color: judge.score === 1 ? "green" : judge.score === 0 ? "red" : "inherit",
"margin-right": "0.25rem",
}}
>
{judge.score === 1 ? "✓" : judge.score === 0 ? "✗" : judge.score}
</span>
)}
</For>
</div>
<Show when={detail.judges && detail.judges.length > 0}>
<For each={detail.judges}>
{(judge) => {
const [expanded, setExpanded] = createSignal(false)
return (
<div style={{ "margin-top": "0.5rem", "padding-left": "1rem" }}>
<div
style={{ "font-size": "0.875rem", cursor: "pointer" }}
onClick={() => setExpanded(!expanded())}
>
<span style={{ "margin-right": "0.5rem" }}>{expanded() ? "▼" : "▶"}</span>
<span
style={{
color: judge.score === 1 ? "green" : judge.score === 0 ? "red" : "inherit",
}}
>
{judge.score === 1 ? "✓" : judge.score === 0 ? "✗" : judge.score}
</span>{" "}
{judge.judge}
</div>
<Show when={expanded()}>
<p
style={{
margin: "0.25rem 0 0 0",
"white-space": "pre-wrap",
"font-size": "0.875rem",
}}
>
{judge.rationale}
</p>
</Show>
</div>
)
}}
</For>
</Show>
</div>
)}
</For>
</div>
)}
</For>
</div>
</Show>
{(() => {
const [jsonExpanded, setJsonExpanded] = createSignal(false)
return (
<div style={{ "margin-top": "1rem" }}>
<button
style={{
cursor: "pointer",
padding: "0.75rem 1.5rem",
"font-size": "1rem",
background: "#f0f0f0",
border: "1px solid #ccc",
"border-radius": "4px",
}}
onClick={() => setJsonExpanded(!jsonExpanded())}
>
<span style={{ "margin-right": "0.5rem" }}>{jsonExpanded() ? "▼" : "▶"}</span>
Raw JSON
</button>
<Show when={jsonExpanded()}>
<pre>{JSON.stringify(task(), null, 2)}</pre>
</Show>
</div>
)
})()}
</Show>
</div>
</main>
)
}
@@ -0,0 +1,86 @@
import { Title } from "@solidjs/meta"
import { A, createAsync, query } from "@solidjs/router"
import { createMemo, For, Show } from "solid-js"
import { Database, desc } from "@opencode-ai/console-core/drizzle/index.js"
import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
interface BenchmarkResult {
averageScore: number
tasks: { averageScore: number; task: { id: string } }[]
}
async function getBenchmarks() {
"use server"
const rows = await Database.use((tx) =>
tx.select().from(BenchmarkTable).orderBy(desc(BenchmarkTable.timeCreated)).limit(100),
)
return rows.map((row) => {
const parsed = JSON.parse(row.result) as BenchmarkResult
const taskScores: Record<string, number> = {}
for (const t of parsed.tasks) {
taskScores[t.task.id] = t.averageScore
}
return {
id: row.id,
agent: row.agent,
model: row.model,
averageScore: parsed.averageScore,
taskScores,
}
})
}
const queryBenchmarks = query(getBenchmarks, "benchmarks.list")
export default function Bench() {
const benchmarks = createAsync(() => queryBenchmarks())
const taskIds = createMemo(() => {
const ids = new Set<string>()
for (const row of benchmarks() ?? []) {
for (const id of Object.keys(row.taskScores)) {
ids.add(id)
}
}
return [...ids].sort()
})
return (
<main data-page="bench" style={{ padding: "2rem" }}>
<Title>Benchmark</Title>
<h1 style={{ "margin-bottom": "1.5rem" }}>Benchmarks</h1>
<table style={{ "border-collapse": "collapse", width: "100%" }}>
<thead>
<tr>
<th style={{ "text-align": "left", padding: "0.75rem" }}>Agent</th>
<th style={{ "text-align": "left", padding: "0.75rem" }}>Model</th>
<th style={{ "text-align": "left", padding: "0.75rem" }}>Score</th>
<For each={taskIds()}>{(id) => <th style={{ "text-align": "left", padding: "0.75rem" }}>{id}</th>}</For>
</tr>
</thead>
<tbody>
<For each={benchmarks()}>
{(row) => (
<tr>
<td style={{ padding: "0.75rem" }}>{row.agent}</td>
<td style={{ padding: "0.75rem" }}>{row.model}</td>
<td style={{ padding: "0.75rem" }}>{row.averageScore.toFixed(3)}</td>
<For each={taskIds()}>
{(id) => (
<td style={{ padding: "0.75rem" }}>
<Show when={row.taskScores[id] !== undefined} fallback="">
<A href={`/bench/${row.id}:${id}`} style={{ color: "#0066cc" }}>
{row.taskScores[id]?.toFixed(3)}
</A>
</Show>
</td>
)}
</For>
</tr>
)}
</For>
</tbody>
</table>
</main>
)
}
@@ -0,0 +1,29 @@
import type { APIEvent } from "@solidjs/start/server"
import { Database } from "@opencode-ai/console-core/drizzle/index.js"
import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
import { Identifier } from "@opencode-ai/console-core/identifier.js"
interface SubmissionBody {
model: string
agent: string
result: string
}
export async function POST(event: APIEvent) {
const body = (await event.request.json()) as SubmissionBody
if (!body.model || !body.agent || !body.result) {
return Response.json({ error: "All fields are required" }, { status: 400 })
}
await Database.use((tx) =>
tx.insert(BenchmarkTable).values({
id: Identifier.create("benchmark"),
model: body.model,
agent: body.agent,
result: body.result,
}),
)
return Response.json({ success: true }, { status: 200 })
}
@@ -124,6 +124,8 @@ export async function handler(
res.status !== 200 &&
// ie. openai 404 error: Item with id 'msg_0ead8b004a3b165d0069436a6b6834819896da85b63b196a3f' not found.
res.status !== 404 &&
// ie. cannot change codex model providers mid-session
!modelInfo.stickyProvider &&
modelInfo.fallbackProvider &&
providerInfo.id !== modelInfo.fallbackProvider
) {
@@ -0,0 +1,12 @@
CREATE TABLE `benchmark` (
`id` varchar(30) NOT NULL,
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
`time_deleted` timestamp(3),
`model` varchar(64) NOT NULL,
`agent` varchar(64) NOT NULL,
`result` mediumtext NOT NULL,
CONSTRAINT `benchmark_id_pk` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE INDEX `time_created` ON `benchmark` (`time_created`);
File diff suppressed because it is too large Load Diff
@@ -274,6 +274,13 @@
"when": 1764110043942,
"tag": "0038_famous_magik",
"breakpoints": true
},
{
"idx": 39,
"version": "5",
"when": 1766946179892,
"tag": "0039_striped_forge",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.202",
"version": "1.0.209",
"private": true,
"type": "module",
"dependencies": {
+1
View File
@@ -5,6 +5,7 @@ export namespace Identifier {
const prefixes = {
account: "acc",
auth: "aut",
benchmark: "ben",
billing: "bil",
key: "key",
model: "mod",
@@ -0,0 +1,14 @@
import { index, mediumtext, mysqlTable, primaryKey, varchar } from "drizzle-orm/mysql-core"
import { id, timestamps } from "../drizzle/types"
export const BenchmarkTable = mysqlTable(
"benchmark",
{
id: id(),
...timestamps,
model: varchar("model", { length: 64 }).notNull(),
agent: varchar("agent", { length: 64 }).notNull(),
result: mediumtext("result").notNull(),
},
(table) => [primaryKey({ columns: [table.id] }), index("time_created").on(table.timeCreated)],
)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.202",
"version": "1.0.209",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+5 -1
View File
@@ -123,7 +123,11 @@ export default {
},
}).then((x) => x.json())) as any
subject = user.id.toString()
email = emails.find((x: any) => x.primary && x.verified)?.email
const primaryEmail = emails.find((x: any) => x.primary)
if (!primaryEmail) throw new Error("No primary email found for GitHub user")
if (!primaryEmail.verified) throw new Error("Primary email for GitHub user not verified")
email = primaryEmail.email
} else if (response.provider === "google") {
if (!response.id.email_verified) throw new Error("Google email not verified")
subject = response.id.sub as string
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.202",
"version": "1.0.209",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+31 -6
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="en" style="background-color: var(--background-base)">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -13,14 +13,39 @@
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<script>
<!-- Theme preload script - applies cached theme to avoid FOUC -->
<script id="oc-theme-preload-script">
;(function () {
const savedTheme = localStorage.getItem("theme") || "oc-1"
document.documentElement.setAttribute("data-theme", savedTheme)
var themeId = localStorage.getItem("opencode-theme-id")
if (!themeId) return
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
var mode = isDark ? "dark" : "light"
document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode
if (themeId === "oc-1") return
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
if (css) {
var style = document.createElement("style")
style.id = "oc-theme-preload"
style.textContent =
":root{color-scheme:" +
mode +
";--text-mix-blend-mode:" +
(isDark ? "plus-lighter" : "multiply") +
";" +
css +
"}"
document.head.appendChild(style)
}
})()
</script>
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-screen"></div>
<script src="/src/index.tsx" type="module"></script>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.0.202",
"version": "1.0.209",
"type": "module",
"scripts": {
"typecheck": "tsgo -b",
+7
View File
@@ -10,6 +10,13 @@ export default defineConfig({
//
// 1. prevent Vite from obscuring rust errors
clearScreen: false,
esbuild: {
// Improves production stack traces (less "kQ@..." noise)
keepNames: true,
},
build: {
sourcemap: true,
},
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.0.202",
"version": "1.0.209",
"private": true,
"type": "module",
"scripts": {
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.0.202"
version = "1.0.209"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/sst/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.202/opencode-darwin-arm64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.209/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.202/opencode-darwin-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.209/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.202/opencode-linux-arm64.tar.gz"
archive = "https://github.com/sst/opencode/releases/download/v1.0.209/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.202/opencode-linux-x64.tar.gz"
archive = "https://github.com/sst/opencode/releases/download/v1.0.209/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.202/opencode-windows-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.209/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.0.202",
"version": "1.0.209",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+2
View File
@@ -2,3 +2,5 @@ preload = ["@opentui/solid/preload"]
[test]
preload = ["./test/preload.ts"]
# Enable code coverage
coverage = true
+11 -3
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.202",
"version": "1.0.209",
"name": "opencode",
"type": "module",
"private": true,
@@ -50,16 +50,23 @@
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.5.1",
"@ai-sdk/amazon-bedrock": "3.0.57",
"@ai-sdk/anthropic": "2.0.50",
"@ai-sdk/anthropic": "2.0.56",
"@ai-sdk/azure": "2.0.73",
"@ai-sdk/cerebras": "1.0.33",
"@ai-sdk/cohere": "2.0.21",
"@ai-sdk/deepinfra": "1.0.30",
"@ai-sdk/gateway": "2.0.23",
"@ai-sdk/google": "2.0.44",
"@ai-sdk/google-vertex": "3.0.81",
"@ai-sdk/groq": "2.0.33",
"@ai-sdk/mcp": "0.0.8",
"@ai-sdk/mistral": "2.0.26",
"@ai-sdk/openai": "2.0.71",
"@ai-sdk/openai-compatible": "1.0.27",
"@ai-sdk/perplexity": "2.0.22",
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.18",
"@ai-sdk/provider-utils": "3.0.19",
"@ai-sdk/togetherai": "1.0.30",
"@ai-sdk/xai": "2.0.42",
"@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5",
@@ -81,6 +88,7 @@
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"bonjour-service": "1.3.0",
"bun-pty": "0.4.2",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
+14
View File
@@ -235,5 +235,19 @@ export default {
],
},
},
{
filetype: "nix",
// TODO: Replace with official tree-sitter-nix WASM when published
// See: https://github.com/nix-community/tree-sitter-nix/issues/66
wasm: "https://github.com/ast-grep/ast-grep.github.io/raw/40b84530640aa83a0d34a20a2b0623d7b8e5ea97/website/public/parsers/tree-sitter-nix.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/nix/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/nix/locals.scm",
],
},
},
],
}
@@ -7,4 +7,5 @@ Rules:
- Do not explain what the user asked for
- Write in first person (I added..., I fixed...)
- Never ask questions or add new questions
- Only exception: if the conversation ends with an unanswered question to the user, preserve that exact question
- If the conversation ends with an unanswered question to the user, preserve that exact question
- If the conversation ends with an imperative statement or request to the user (e.g. "Now please run the command and paste the console output"), always include that exact request in the summary
+1 -1
View File
@@ -22,7 +22,7 @@ Your output must be:
- The title should NEVER include "summarizing" or "generating" when generating a title
- DO NOT SAY YOU CANNOT GENERATE A TITLE OR COMPLAIN ABOUT THE INPUT
- Always output something meaningful, even if the input is minimal.
- If the user message is short or conversational (e.g. "hello", "lol", "whats up", "hey"):
- If the user message is short or conversational (e.g. "hello", "lol", "what's up", "hey"):
→ create a title that reflects the user's tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.)
</rules>
+8 -20
View File
@@ -5,6 +5,7 @@ import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
import { ACP } from "@/acp/agent"
import { Server } from "@/server/server"
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
const log = Log.create({ service: "acp-command" })
@@ -19,29 +20,16 @@ export const AcpCommand = cmd({
command: "acp",
describe: "start ACP (Agent Client Protocol) server",
builder: (yargs) => {
return yargs
.option("cwd", {
describe: "working directory",
type: "string",
default: process.cwd(),
})
.option("port", {
type: "number",
describe: "port to listen on",
default: 0,
})
.option("hostname", {
type: "string",
describe: "hostname to listen on",
default: "127.0.0.1",
})
return withNetworkOptions(yargs).option("cwd", {
describe: "working directory",
type: "string",
default: process.cwd(),
})
},
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const server = Server.listen({
port: args.port,
hostname: args.hostname,
})
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`,
+118 -30
View File
@@ -9,7 +9,9 @@ import * as github from "@actions/github"
import type { Context } from "@actions/github/lib/context"
import type {
IssueCommentEvent,
IssuesEvent,
PullRequestReviewCommentEvent,
WorkflowDispatchEvent,
WorkflowRunEvent,
PullRequestEvent,
} from "@octokit/webhooks-types"
@@ -132,7 +134,16 @@ type IssueQueryResponse = {
const AGENT_USERNAME = "opencode-agent[bot]"
const AGENT_REACTION = "eyes"
const WORKFLOW_FILE = ".github/workflows/opencode.yml"
const SUPPORTED_EVENTS = ["issue_comment", "pull_request_review_comment", "schedule", "pull_request"] as const
// Event categories for routing
// USER_EVENTS: triggered by user actions, have actor/issueId, support reactions/comments
// REPO_EVENTS: triggered by automation, no actor/issueId, output to logs/PR only
const USER_EVENTS = ["issue_comment", "pull_request_review_comment", "issues", "pull_request"] as const
const REPO_EVENTS = ["schedule", "workflow_dispatch"] as const
const SUPPORTED_EVENTS = [...USER_EVENTS, ...REPO_EVENTS] as const
type UserEvent = (typeof USER_EVENTS)[number]
type RepoEvent = (typeof REPO_EVENTS)[number]
// Parses GitHub remote URLs in various formats:
// - https://github.com/owner/repo.git
@@ -147,6 +158,29 @@ export function parseGitHubRemote(url: string): { owner: string; repo: string }
return { owner: match[1], repo: match[2] }
}
/**
* Extracts displayable text from assistant response parts.
* Returns null for tool-only or reasoning-only responses (signals summary needed).
* Throws for truly unusable responses (empty, step-start only, etc.).
*/
export function extractResponseText(parts: MessageV2.Part[]): string | null {
// Priority 1: Look for text parts
const textPart = parts.findLast((p) => p.type === "text")
if (textPart) return textPart.text
// Priority 2: Reasoning-only - return null to signal summary needed
const reasoningPart = parts.findLast((p) => p.type === "reasoning")
if (reasoningPart) return null
// Priority 3: Tool-only - return null to signal summary needed
const toolParts = parts.filter((p) => p.type === "tool" && p.state.status === "completed")
if (toolParts.length > 0) return null
// No usable parts - throw with debug info
const partTypes = parts.map((p) => p.type).join(", ") || "none"
throw new Error(`Failed to parse response. Part types found: [${partTypes}]`)
}
export const GithubCommand = cmd({
command: "github",
describe: "manage GitHub agent",
@@ -397,27 +431,38 @@ export const GithubRunCommand = cmd({
core.setFailed(`Unsupported event type: ${context.eventName}`)
process.exit(1)
}
// Determine event category for routing
// USER_EVENTS: have actor, issueId, support reactions/comments
// REPO_EVENTS: no actor/issueId, output to logs/PR only
const isUserEvent = USER_EVENTS.includes(context.eventName as UserEvent)
const isRepoEvent = REPO_EVENTS.includes(context.eventName as RepoEvent)
const isCommentEvent = ["issue_comment", "pull_request_review_comment"].includes(context.eventName)
const isIssuesEvent = context.eventName === "issues"
const isScheduleEvent = context.eventName === "schedule"
const isWorkflowDispatchEvent = context.eventName === "workflow_dispatch"
const { providerID, modelID } = normalizeModel()
const runId = normalizeRunId()
const share = normalizeShare()
const oidcBaseUrl = normalizeOidcBaseUrl()
const { owner, repo } = context.repo
// For schedule events, payload has no issue/comment data
// For repo events (schedule, workflow_dispatch), payload has no issue/comment data
const payload = context.payload as
| IssueCommentEvent
| IssuesEvent
| PullRequestReviewCommentEvent
| WorkflowDispatchEvent
| WorkflowRunEvent
| PullRequestEvent
const issueEvent = isIssueCommentEvent(payload) ? payload : undefined
// workflow_dispatch has an actor (the user who triggered it), schedule does not
const actor = isScheduleEvent ? undefined : context.actor
const issueId = isScheduleEvent
const issueId = isRepoEvent
? undefined
: context.eventName === "issue_comment"
? (payload as IssueCommentEvent).issue.number
: context.eventName === "issue_comment" || context.eventName === "issues"
? (payload as IssueCommentEvent | IssuesEvent).issue.number
: (payload as PullRequestEvent | PullRequestReviewCommentEvent).pull_request.number
const runUrl = `/${owner}/${repo}/actions/runs/${runId}`
const shareBaseUrl = isMock ? "https://dev.opencode.ai" : "https://opencode.ai"
@@ -462,8 +507,8 @@ export const GithubRunCommand = cmd({
if (!useGithubToken) {
await configureGit(appToken)
}
// Skip permission check for schedule events (no actor to check)
if (!isScheduleEvent) {
// Skip permission check and reactions for repo events (no actor to check, no issue to react to)
if (isUserEvent) {
await assertPermissions()
await addReaction(commentType)
}
@@ -480,25 +525,30 @@ export const GithubRunCommand = cmd({
})()
console.log("opencode session", session.id)
// Handle 4 cases
// 1. Schedule (no issue/PR context)
// 2. Issue
// 3. Local PR
// 4. Fork PR
if (isScheduleEvent) {
// Schedule event - no issue/PR context, output goes to logs
const branch = await checkoutNewBranch("schedule")
// Handle event types:
// REPO_EVENTS (schedule, workflow_dispatch): no issue/PR context, output to logs/PR only
// USER_EVENTS on PR (pull_request, pull_request_review_comment, issue_comment on PR): work on PR branch
// USER_EVENTS on Issue (issue_comment on issue, issues): create new branch, may create PR
if (isRepoEvent) {
// Repo event - no issue/PR context, output goes to logs
if (isWorkflowDispatchEvent && actor) {
console.log(`Triggered by: ${actor}`)
}
const branchPrefix = isWorkflowDispatchEvent ? "dispatch" : "schedule"
const branch = await checkoutNewBranch(branchPrefix)
const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const response = await chat(userPrompt, promptFiles)
const { dirty, uncommittedChanges } = await branchIsDirty(head)
if (dirty) {
const summary = await summarize(response)
await pushToNewBranch(summary, branch, uncommittedChanges, true)
// workflow_dispatch has an actor for co-author attribution, schedule does not
await pushToNewBranch(summary, branch, uncommittedChanges, isScheduleEvent)
const triggerType = isWorkflowDispatchEvent ? "workflow_dispatch" : "scheduled workflow"
const pr = await createPR(
repoData.data.default_branch,
branch,
summary,
`${response}\n\nTriggered by scheduled workflow${footer({ image: true })}`,
`${response}\n\nTriggered by ${triggerType}${footer({ image: true })}`,
)
console.log(`Created PR #${pr}`)
} else {
@@ -573,7 +623,7 @@ export const GithubRunCommand = cmd({
} else if (e instanceof Error) {
msg = e.message
}
if (!isScheduleEvent) {
if (isUserEvent) {
await createComment(`${msg}${footer()}`)
await removeReaction(commentType)
}
@@ -628,9 +678,15 @@ export const GithubRunCommand = cmd({
}
function isIssueCommentEvent(
event: IssueCommentEvent | PullRequestReviewCommentEvent | WorkflowRunEvent | PullRequestEvent,
event:
| IssueCommentEvent
| IssuesEvent
| PullRequestReviewCommentEvent
| WorkflowDispatchEvent
| WorkflowRunEvent
| PullRequestEvent,
): event is IssueCommentEvent {
return "issue" in event
return "issue" in event && "comment" in event
}
function getReviewCommentContext() {
@@ -652,10 +708,11 @@ export const GithubRunCommand = cmd({
async function getUserPrompt() {
const customPrompt = process.env["PROMPT"]
// For schedule events, PROMPT is required since there's no comment to extract from
if (isScheduleEvent) {
// For repo events and issues events, PROMPT is required since there's no comment to extract from
if (isRepoEvent || isIssuesEvent) {
if (!customPrompt) {
throw new Error("PROMPT input is required for scheduled events")
const eventType = isRepoEvent ? "scheduled and workflow_dispatch" : "issues"
throw new Error(`PROMPT input is required for ${eventType} events`)
}
return { userPrompt: customPrompt, promptFiles: [] }
}
@@ -856,10 +913,41 @@ export const GithubRunCommand = cmd({
)
}
const match = result.parts.findLast((p) => p.type === "text")
if (!match) throw new Error("Failed to parse the text response")
const text = extractResponseText(result.parts)
if (text) return text
return match.text
// No text part (tool-only or reasoning-only) - ask agent to summarize
console.log("Requesting summary from agent...")
const summary = await SessionPrompt.prompt({
sessionID: session.id,
messageID: Identifier.ascending("message"),
model: {
providerID,
modelID,
},
tools: { "*": false }, // Disable all tools to force text response
parts: [
{
id: Identifier.ascending("part"),
type: "text",
text: "Summarize the actions (tool calls & reasoning) you did for the user in 1-2 sentences.",
},
],
})
if (summary.info.role === "assistant" && summary.info.error) {
console.error(summary.info)
throw new Error(
`${summary.info.error.name}: ${"message" in summary.info.error ? summary.info.error.message : ""}`,
)
}
const summaryText = extractResponseText(summary.parts)
if (!summaryText) {
throw new Error("Failed to get summary from agent")
}
return summaryText
}
async function getOidcToken() {
@@ -923,7 +1011,7 @@ export const GithubRunCommand = cmd({
await $`git config --local ${config} "${gitConfig}"`
}
async function checkoutNewBranch(type: "issue" | "schedule") {
async function checkoutNewBranch(type: "issue" | "schedule" | "dispatch") {
console.log("Checking out new branch...")
const branch = generateBranchName(type)
await $`git checkout -b ${branch}`
@@ -952,16 +1040,16 @@ export const GithubRunCommand = cmd({
await $`git checkout -b ${localBranch} fork/${remoteBranch}`
}
function generateBranchName(type: "issue" | "pr" | "schedule") {
function generateBranchName(type: "issue" | "pr" | "schedule" | "dispatch") {
const timestamp = new Date()
.toISOString()
.replace(/[:-]/g, "")
.replace(/\.\d{3}Z/, "")
.split("T")
.join("")
if (type === "schedule") {
if (type === "schedule" || type === "dispatch") {
const hex = crypto.randomUUID().slice(0, 6)
return `opencode/scheduled-${hex}-${timestamp}`
return `opencode/${type}-${hex}-${timestamp}`
}
return `opencode/${type}${issueId}-${timestamp}`
}
+4 -19
View File
@@ -1,29 +1,14 @@
import { Server } from "../../server/server"
import { cmd } from "./cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
export const ServeCommand = cmd({
command: "serve",
builder: (yargs) =>
yargs
.option("port", {
alias: ["p"],
type: "number",
describe: "port to listen on",
default: 0,
})
.option("hostname", {
type: "string",
describe: "hostname to listen on",
default: "127.0.0.1",
}),
builder: (yargs) => withNetworkOptions(yargs),
describe: "starts a headless opencode server",
handler: async (args) => {
const hostname = args.hostname
const port = args.port
const server = Server.listen({
port,
hostname,
})
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
await new Promise(() => {})
await server.stop()
+14 -5
View File
@@ -82,12 +82,21 @@ async function getAllSessions(): Promise<Session.Info[]> {
return sessions
}
async function aggregateSessionStats(days?: number, projectFilter?: string): Promise<SessionStats> {
export async function aggregateSessionStats(days?: number, projectFilter?: string): Promise<SessionStats> {
const sessions = await getAllSessions()
const DAYS_IN_SECOND = 24 * 60 * 60 * 1000
const cutoffTime = days ? Date.now() - days * DAYS_IN_SECOND : 0
const MS_IN_DAY = 24 * 60 * 60 * 1000
let filteredSessions = days ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions
const cutoffTime = (() => {
if (days === undefined) return 0
if (days === 0) {
const now = new Date()
now.setHours(0, 0, 0, 0)
return now.getTime()
}
return Date.now() - days * MS_IN_DAY
})()
let filteredSessions = cutoffTime > 0 ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions
if (projectFilter !== undefined) {
if (projectFilter === "") {
@@ -198,7 +207,7 @@ async function aggregateSessionStats(days?: number, projectFilter?: string): Pro
}
}
const actualDays = Math.max(1, Math.ceil((latestTime - earliestTime) / DAYS_IN_SECOND))
const actualDays = Math.max(1, Math.ceil((latestTime - earliestTime) / MS_IN_DAY))
stats.dateRange = {
earliest: earliestTime,
latest: latestTime,
+1 -1
View File
@@ -539,7 +539,7 @@ function App() {
sdk.event.on(SessionApi.Event.Error.type, (evt) => {
const error = evt.properties.error
const message = (() => {
if (!error) return "An error occured"
if (!error) return "An error occurred"
if (typeof error === "object") {
const data = error.data
@@ -37,11 +37,9 @@ export function DialogModel(props: { providerID?: string }) {
const recents = local.model.recent()
const recentList = showExtra()
? recents
.filter(
(item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
)
.slice(0, 5)
? recents.filter(
(item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
)
: []
const favoriteOptions = favorites.flatMap((item) => {
@@ -182,7 +180,10 @@ export function DialogModel(props: { providerID?: string }) {
// Apply fuzzy filtering to each section separately, maintaining section order
if (q) {
const filteredFavorites = fuzzysort.go(q, favoriteOptions, { keys: ["title"] }).map((x) => x.obj)
const filteredRecents = fuzzysort.go(q, recentOptions, { keys: ["title"] }).map((x) => x.obj)
const filteredRecents = fuzzysort
.go(q, recentOptions, { keys: ["title"] })
.map((x) => x.obj)
.slice(0, 5)
const filteredProviders = fuzzysort.go(q, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj)
const filteredPopular = fuzzysort.go(q, popularProviders, { keys: ["title"] }).map((x) => x.obj)
return [...filteredFavorites, ...filteredRecents, ...filteredProviders, ...filteredPopular]
@@ -5,6 +5,7 @@ import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { useSDK } from "../context/sdk"
import { DialogPrompt } from "../ui/dialog-prompt"
import { Link } from "../ui/link"
import { useTheme } from "../context/theme"
import { TextAttributes } from "@opentui/core"
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2"
@@ -128,7 +129,7 @@ function AutoMethod(props: AutoMethodProps) {
<text fg={theme.textMuted}>esc</text>
</box>
<box gap={1}>
<text fg={theme.primary}>{props.authorization.url}</text>
<Link href={props.authorization.url} fg={theme.primary} />
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
</box>
<text fg={theme.textMuted}>Waiting for authorization...</text>
@@ -170,7 +171,7 @@ function CodeMethod(props: CodeMethodProps) {
description={() => (
<box gap={1}>
<text fg={theme.textMuted}>{props.authorization.instructions}</text>
<text fg={theme.primary}>{props.authorization.url}</text>
<Link href={props.authorization.url} fg={theme.primary} />
<Show when={error()}>
<text fg={theme.error}>Invalid code</text>
</Show>
@@ -2,12 +2,13 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { createEffect, createMemo, createSignal, onMount } from "solid-js"
import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
import { Locale } from "@/util/locale"
import { Keybind } from "@/util/keybind"
import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk"
import { DialogSessionRename } from "./dialog-session-rename"
import { useKV } from "../context/kv"
import "opentui-spinner/solid"
export function DialogSessionList() {
@@ -16,6 +17,7 @@ export function DialogSessionList() {
const { theme } = useTheme()
const route = useRoute()
const sdk = useSDK()
const kv = useKV()
const [toDelete, setToDelete] = createSignal<string>()
@@ -45,7 +47,11 @@ export function DialogSessionList() {
value: x.id,
category,
footer: Locale.time(x.time.updated),
gutter: isWorking ? <spinner frames={spinnerFrames} interval={80} color={theme.primary} /> : undefined,
gutter: isWorking ? (
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}>
<spinner frames={spinnerFrames} interval={80} color={theme.primary} />
</Show>
) : undefined,
}
})
.slice(0, 150)
@@ -12,6 +12,38 @@ import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "@/util/locale"
import type { PromptInfo } from "./history"
function removeLineRange(input: string) {
const hashIndex = input.lastIndexOf("#")
return hashIndex !== -1 ? input.substring(0, hashIndex) : input
}
function extractLineRange(input: string) {
const hashIndex = input.lastIndexOf("#")
if (hashIndex === -1) {
return { baseQuery: input }
}
const baseName = input.substring(0, hashIndex)
const linePart = input.substring(hashIndex + 1)
const lineMatch = linePart.match(/^(\d+)(?:-(\d*))?$/)
if (!lineMatch) {
return { baseQuery: baseName }
}
const startLine = Number(lineMatch[1])
const endLine = lineMatch[2] && startLine < Number(lineMatch[2]) ? Number(lineMatch[2]) : undefined
return {
lineRange: {
baseName,
startLine,
endLine,
},
baseQuery: baseName,
}
}
export type AutocompleteRef = {
onInput: (value: string) => void
onKeyDown: (e: KeyEvent) => void
@@ -142,9 +174,11 @@ export function Autocomplete(props: {
async (query) => {
if (!store.visible || store.visible === "/") return []
const { lineRange, baseQuery } = extractLineRange(query ?? "")
// Get files from SDK
const result = await sdk.client.find.files({
query: query ?? "",
query: baseQuery,
})
const options: AutocompleteOption[] = []
@@ -153,15 +187,27 @@ export function Autocomplete(props: {
if (!result.error && result.data) {
const width = props.anchor().width - 4
options.push(
...result.data.map(
(item): AutocompleteOption => ({
display: Locale.truncateMiddle(item, width),
...result.data.map((item): AutocompleteOption => {
let url = `file://${process.cwd()}/${item}`
let filename = item
if (lineRange && !item.endsWith("/")) {
filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
const urlObj = new URL(url)
urlObj.searchParams.set("start", String(lineRange.startLine))
if (lineRange.endLine !== undefined) {
urlObj.searchParams.set("end", String(lineRange.endLine))
}
url = urlObj.toString()
}
return {
display: Locale.truncateMiddle(filename, width),
onSelect: () => {
insertPart(item, {
insertPart(filename, {
type: "file",
mime: "text/plain",
filename: item,
url: `file://${process.cwd()}/${item}`,
filename,
url,
source: {
type: "file",
text: {
@@ -173,8 +219,8 @@ export function Autocomplete(props: {
},
})
},
}),
),
}
}),
)
}
@@ -383,8 +429,8 @@ export function Autocomplete(props: {
return prev
}
const result = fuzzysort.go(currentFilter, mixed, {
keys: [(obj) => obj.display.trimEnd(), "description", (obj) => obj.aliases?.join(" ") ?? ""],
const result = fuzzysort.go(removeLineRange(currentFilter), mixed, {
keys: [(obj) => removeLineRange(obj.display.trimEnd()), "description", (obj) => obj.aliases?.join(" ") ?? ""],
limit: 10,
scoreFn: (objResults) => {
const displayResult = objResults[0]
@@ -29,6 +29,7 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
import { DialogAlert } from "../../ui/dialog-alert"
import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
export type PromptProps = {
sessionID?: string
@@ -124,6 +125,7 @@ export function Prompt(props: PromptProps) {
const command = useCommandDialog()
const renderer = useRenderer()
const { theme, syntax } = useTheme()
const kv = useKV()
function promptModelWarning() {
toast.show({
@@ -996,8 +998,11 @@ export function Prompt(props: PromptProps) {
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
{/* @ts-ignore // SpinnerOptions doesn't support marginLeft */}
<spinner marginLeft={1} color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
<box marginLeft={1}>
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
@@ -52,7 +52,6 @@ import { DialogMessage } from "./dialog-message"
import type { PromptInfo } from "../../component/prompt/history"
import { iife } from "@/util/iife"
import { DialogConfirm } from "@tui/ui/dialog-confirm"
import { DialogPrompt } from "@tui/ui/dialog-prompt"
import { DialogTimeline } from "./dialog-timeline"
import { DialogForkFromTimeline } from "./dialog-fork-from-timeline"
import { DialogSessionRename } from "../../component/dialog-session-rename"
@@ -67,7 +66,7 @@ import stripAnsi from "strip-ansi"
import { Footer } from "./footer.tsx"
import { usePromptRef } from "../../context/prompt"
import { Filesystem } from "@/util/filesystem"
import { DialogSubagent } from "./dialog-subagent.tsx"
import { DialogExportOptions } from "../../ui/dialog-export-options"
addDefaultParsers(parsers.parsers)
@@ -128,6 +127,7 @@ export function Session() {
const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false))
const [userMessageMarkdown, setUserMessageMarkdown] = createSignal(kv.get("user_message_markdown", true))
const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
const [animationsEnabled, setAnimationsEnabled] = createSignal(kv.get("animations_enabled", true))
const wide = createMemo(() => dimensions().width > 120)
const sidebarVisible = createMemo(() => {
@@ -584,6 +584,19 @@ export function Session() {
dialog.clear()
},
},
{
title: animationsEnabled() ? "Disable animations" : "Enable animations",
value: "session.toggle.animations",
category: "Session",
onSelect: (dialog) => {
setAnimationsEnabled((prev) => {
const next = !prev
kv.set("animations_enabled", next)
return next
})
dialog.clear()
},
},
{
title: "Page up",
value: "session.page.up",
@@ -770,8 +783,22 @@ export function Session() {
for (const part of parts) {
if (part.type === "text" && !part.synthetic) {
transcript += `${part.text}\n\n`
} else if (part.type === "reasoning") {
if (showThinking()) {
transcript += `_Thinking:_\n\n${part.text}\n\n`
}
} else if (part.type === "tool") {
transcript += `\`\`\`\nTool: ${part.tool}\n\`\`\`\n\n`
transcript += `\`\`\`\nTool: ${part.tool}\n`
if (showDetails() && part.state.input) {
transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\``
}
if (showDetails() && part.state.status === "completed" && part.state.output) {
transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\``
}
if (showDetails() && part.state.status === "error" && part.state.error) {
transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\``
}
transcript += `\n\`\`\`\n\n`
}
}
@@ -798,6 +825,14 @@ export function Session() {
const sessionData = session()
const sessionMessages = messages()
const defaultFilename = `session-${sessionData.id.slice(0, 8)}.md`
const options = await DialogExportOptions.show(dialog, defaultFilename, showThinking(), showDetails())
if (options === null) return
const { filename: customFilename, thinking: includeThinking, toolDetails: includeToolDetails } = options
let transcript = `# ${sessionData.title}\n\n`
transcript += `**Session ID:** ${sessionData.id}\n`
transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n`
@@ -812,22 +847,28 @@ export function Session() {
for (const part of parts) {
if (part.type === "text" && !part.synthetic) {
transcript += `${part.text}\n\n`
} else if (part.type === "reasoning") {
if (includeThinking) {
transcript += `_Thinking:_\n\n${part.text}\n\n`
}
} else if (part.type === "tool") {
transcript += `\`\`\`\nTool: ${part.tool}\n\`\`\`\n\n`
transcript += `\`\`\`\nTool: ${part.tool}\n`
if (includeToolDetails && part.state.input) {
transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\``
}
if (includeToolDetails && part.state.status === "completed" && part.state.output) {
transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\``
}
if (includeToolDetails && part.state.status === "error" && part.state.error) {
transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\``
}
transcript += `\n\`\`\`\n\n`
}
}
transcript += `---\n\n`
}
// Prompt for optional filename
const customFilename = await DialogPrompt.show(dialog, "Export filename", {
value: `session-${sessionData.id.slice(0, 8)}.md`,
})
// Cancel if user pressed escape
if (customFilename === null) return
// Save to file in current working directory
const exportDir = process.cwd()
const filename = customFilename.trim()
+7 -19
View File
@@ -3,31 +3,19 @@ import { Instance } from "@/project/instance"
import path from "path"
import { Server } from "@/server/server"
import { upgrade } from "@/cli/upgrade"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
export const TuiSpawnCommand = cmd({
command: "spawn [project]",
builder: (yargs) =>
yargs
.positional("project", {
type: "string",
describe: "path to start opencode in",
})
.option("port", {
type: "number",
describe: "port to listen on",
default: 0,
})
.option("hostname", {
type: "string",
describe: "hostname to listen on",
default: "127.0.0.1",
}),
withNetworkOptions(yargs).positional("project", {
type: "string",
describe: "path to start opencode in",
}),
handler: async (args) => {
upgrade()
const server = Server.listen({
port: args.port,
hostname: "127.0.0.1",
})
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
const bin = process.execPath
const cmd = []
let cwd = process.cwd()
+4 -16
View File
@@ -6,6 +6,7 @@ import path from "path"
import { UI } from "@/cli/ui"
import { iife } from "@/util/iife"
import { Log } from "@/util/log"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
declare global {
const OPENCODE_WORKER_PATH: string
@@ -15,7 +16,7 @@ export const TuiThreadCommand = cmd({
command: "$0 [project]",
describe: "start opencode tui",
builder: (yargs) =>
yargs
withNetworkOptions(yargs)
.positional("project", {
type: "string",
describe: "path to start opencode in",
@@ -36,23 +37,12 @@ export const TuiThreadCommand = cmd({
describe: "session id to continue",
})
.option("prompt", {
alias: ["p"],
type: "string",
describe: "prompt to use",
})
.option("agent", {
type: "string",
describe: "agent to use",
})
.option("port", {
type: "number",
describe: "port to listen on",
default: 0,
})
.option("hostname", {
type: "string",
describe: "hostname to listen on",
default: "127.0.0.1",
}),
handler: async (args) => {
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
@@ -87,10 +77,8 @@ export const TuiThreadCommand = cmd({
process.on("unhandledRejection", (e) => {
Log.Default.error(e)
})
const server = await client.call("server", {
port: args.port,
hostname: args.hostname,
})
const opts = await resolveNetworkOptions(args)
const server = await client.call("server", opts)
const prompt = await iife(async () => {
const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined
if (!args.prompt) return piped
@@ -0,0 +1,148 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { onMount, Show, type JSX } from "solid-js"
import { useKeyboard } from "@opentui/solid"
export type DialogExportOptionsProps = {
defaultFilename: string
defaultThinking: boolean
defaultToolDetails: boolean
onConfirm?: (options: { filename: string; thinking: boolean; toolDetails: boolean }) => void
onCancel?: () => void
}
export function DialogExportOptions(props: DialogExportOptionsProps) {
const dialog = useDialog()
const { theme } = useTheme()
let textarea: TextareaRenderable
const [store, setStore] = createStore({
thinking: props.defaultThinking,
toolDetails: props.defaultToolDetails,
active: "filename" as "filename" | "thinking" | "toolDetails",
})
useKeyboard((evt) => {
if (evt.name === "return") {
props.onConfirm?.({
filename: textarea.plainText,
thinking: store.thinking,
toolDetails: store.toolDetails,
})
}
if (evt.name === "tab") {
const order: Array<"filename" | "thinking" | "toolDetails"> = ["filename", "thinking", "toolDetails"]
const currentIndex = order.indexOf(store.active)
const nextIndex = (currentIndex + 1) % order.length
setStore("active", order[nextIndex])
evt.preventDefault()
}
if (evt.name === "space") {
if (store.active === "thinking") setStore("thinking", !store.thinking)
if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
evt.preventDefault()
}
})
onMount(() => {
dialog.setSize("medium")
setTimeout(() => {
textarea.focus()
}, 1)
textarea.gotoLineEnd()
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Export Options
</text>
<text fg={theme.textMuted}>esc</text>
</box>
<box gap={1}>
<box>
<text fg={theme.text}>Filename:</text>
</box>
<textarea
onSubmit={() => {
props.onConfirm?.({
filename: textarea.plainText,
thinking: store.thinking,
toolDetails: store.toolDetails,
})
}}
height={3}
keyBindings={[{ name: "return", action: "submit" }]}
ref={(val: TextareaRenderable) => (textarea = val)}
initialValue={props.defaultFilename}
placeholder="Enter filename"
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.text}
/>
</box>
<box flexDirection="column">
<box
flexDirection="row"
gap={2}
paddingLeft={1}
backgroundColor={store.active === "thinking" ? theme.backgroundElement : undefined}
onMouseUp={() => setStore("active", "thinking")}
>
<text fg={store.active === "thinking" ? theme.primary : theme.textMuted}>
{store.thinking ? "[x]" : "[ ]"}
</text>
<text fg={store.active === "thinking" ? theme.primary : theme.text}>Include thinking</text>
</box>
<box
flexDirection="row"
gap={2}
paddingLeft={1}
backgroundColor={store.active === "toolDetails" ? theme.backgroundElement : undefined}
onMouseUp={() => setStore("active", "toolDetails")}
>
<text fg={store.active === "toolDetails" ? theme.primary : theme.textMuted}>
{store.toolDetails ? "[x]" : "[ ]"}
</text>
<text fg={store.active === "toolDetails" ? theme.primary : theme.text}>Include tool details</text>
</box>
</box>
<Show when={store.active !== "filename"}>
<text fg={theme.textMuted} paddingBottom={1}>
Press <span style={{ fg: theme.text }}>space</span> to toggle, <span style={{ fg: theme.text }}>return</span>{" "}
to confirm
</text>
</Show>
<Show when={store.active === "filename"}>
<text fg={theme.textMuted} paddingBottom={1}>
Press <span style={{ fg: theme.text }}>return</span> to confirm, <span style={{ fg: theme.text }}>tab</span>{" "}
for options
</text>
</Show>
</box>
)
}
DialogExportOptions.show = (
dialog: DialogContext,
defaultFilename: string,
defaultThinking: boolean,
defaultToolDetails: boolean,
) => {
return new Promise<{ filename: string; thinking: boolean; toolDetails: boolean } | null>((resolve) => {
dialog.replace(
() => (
<DialogExportOptions
defaultFilename={defaultFilename}
defaultThinking={defaultThinking}
defaultToolDetails={defaultToolDetails}
onConfirm={(options) => resolve(options)}
onCancel={() => resolve(null)}
/>
),
() => resolve(null),
)
})
}
@@ -0,0 +1,28 @@
import type { JSX } from "solid-js"
import type { RGBA } from "@opentui/core"
import open from "open"
export interface LinkProps {
href: string
children?: JSX.Element | string
fg?: RGBA
}
/**
* Link component that renders clickable hyperlinks.
* Clicking anywhere on the link text opens the URL in the default browser.
*/
export function Link(props: LinkProps) {
const displayText = props.children ?? props.href
return (
<text
fg={props.fg}
onMouseUp={() => {
open(props.href).catch(() => {})
}}
>
{displayText}
</text>
)
}
+1 -1
View File
@@ -30,7 +30,7 @@ process.on("uncaughtException", (e) => {
let server: Bun.Server<BunWebSocketData>
export const rpc = {
async server(input: { port: number; hostname: string }) {
async server(input: { port: number; hostname: string; mdns?: boolean }) {
if (server) await server.stop(true)
try {
server = Server.listen(input)
+9 -20
View File
@@ -1,6 +1,7 @@
import { Server } from "../../server/server"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import open from "open"
import { networkInterfaces } from "os"
@@ -28,32 +29,16 @@ function getNetworkIPs() {
export const WebCommand = cmd({
command: "web",
builder: (yargs) =>
yargs
.option("port", {
alias: ["p"],
type: "number",
describe: "port to listen on",
default: 0,
})
.option("hostname", {
type: "string",
describe: "hostname to listen on",
default: "127.0.0.1",
}),
builder: (yargs) => withNetworkOptions(yargs),
describe: "starts a headless opencode server",
handler: async (args) => {
const hostname = args.hostname
const port = args.port
const server = Server.listen({
port,
hostname,
})
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
UI.empty()
UI.println(UI.logo(" "))
UI.empty()
if (hostname === "0.0.0.0") {
if (opts.hostname === "0.0.0.0") {
// Show localhost for local access
const localhostUrl = `http://localhost:${server.port}`
UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, localhostUrl)
@@ -70,6 +55,10 @@ export const WebCommand = cmd({
}
}
if (opts.mdns) {
UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, "opencode.local")
}
// Open localhost in browser
open(localhostUrl.toString()).catch(() => {})
} else {
+43
View File
@@ -0,0 +1,43 @@
import type { Argv, InferredOptionTypes } from "yargs"
import { Config } from "../config/config"
const options = {
port: {
type: "number" as const,
describe: "port to listen on",
default: 0,
},
hostname: {
type: "string" as const,
describe: "hostname to listen on",
default: "127.0.0.1",
},
mdns: {
type: "boolean" as const,
describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)",
default: false,
},
}
export type NetworkOptions = InferredOptionTypes<typeof options>
export function withNetworkOptions<T>(yargs: Argv<T>) {
return yargs.options(options)
}
export async function resolveNetworkOptions(args: NetworkOptions) {
const config = await Config.global()
const portExplicitlySet = process.argv.includes("--port")
const hostnameExplicitlySet = process.argv.includes("--hostname")
const mdnsExplicitlySet = process.argv.includes("--mdns")
const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns)
const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port)
const hostname = hostnameExplicitlySet
? args.hostname
: mdns && !config?.server?.hostname
? "0.0.0.0"
: (config?.server?.hostname ?? args.hostname)
return { hostname, port, mdns }
}
+33 -2
View File
@@ -98,7 +98,7 @@ export namespace Config {
for (const file of ["opencode.jsonc", "opencode.json"]) {
log.debug(`loading config from ${path.join(dir, file)}`)
result = mergeConfigWithPlugins(result, await loadFile(path.join(dir, file)))
// to satisy the type checker
// to satisfy the type checker
result.agent ??= {}
result.mode ??= {}
result.plugin ??= []
@@ -141,13 +141,21 @@ export namespace Config {
if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({})
// Apply flag overrides for compaction settings
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
result.compaction = { ...result.compaction, auto: false }
}
if (Flag.OPENCODE_DISABLE_PRUNE) {
result.compaction = { ...result.compaction, prune: false }
}
return {
config: result,
directories,
}
})
const INVALID_DIRS = new Bun.Glob(`{${["agents", "commands", "plugins", "tools"].join(",")}}/`)
const INVALID_DIRS = new Bun.Glob(`{${["agents", "commands", "plugins", "tools", "skills"].join(",")}}/`)
async function assertValid(dir: string) {
const invalid = await Array.fromAsync(
INVALID_DIRS.scan({
@@ -183,6 +191,10 @@ export namespace Config {
cwd: dir,
},
).catch(() => {})
// Install any additional dependencies defined in the package.json
// This allows local plugins and custom tools to use external packages
await BunProc.run(["install"], { cwd: dir }).catch(() => {})
}
const COMMAND_GLOB = new Bun.Glob("command/**/*.md")
@@ -587,6 +599,17 @@ export namespace Config {
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
})
export const Server = z
.object({
port: z.number().int().positive().optional().describe("Port to listen on"),
hostname: z.string().optional().describe("Hostname to listen on"),
mdns: z.boolean().optional().describe("Enable mDNS service discovery"),
})
.strict()
.meta({
ref: "ServerConfig",
})
export const Layout = z.enum(["auto", "stretch"]).meta({
ref: "LayoutConfig",
})
@@ -633,7 +656,9 @@ export namespace Config {
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
theme: z.string().optional().describe("Theme name to use for the interface"),
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
logLevel: Log.Level.optional().describe("Log level"),
tui: TUI.optional().describe("TUI specific settings"),
server: Server.optional().describe("Server configuration for opencode serve and web commands"),
command: z
.record(z.string(), Command)
.optional()
@@ -778,6 +803,12 @@ export namespace Config {
url: z.string().optional().describe("Enterprise URL"),
})
.optional(),
compaction: z
.object({
auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"),
prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"),
})
.optional(),
experimental: z
.object({
hook: z
+15
View File
@@ -7,6 +7,7 @@ import path from "path"
import fs from "fs"
import ignore from "ignore"
import { Log } from "../util/log"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Ripgrep } from "./ripgrep"
import fuzzysort from "fuzzysort"
@@ -235,6 +236,13 @@ export namespace File {
using _ = log.time("read", { file })
const project = Instance.project
const full = path.join(Instance.directory, file)
// TODO: Filesystem.contains is lexical only - symlinks inside the project can escape.
// TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization.
if (!Filesystem.contains(Instance.directory, full)) {
throw new Error(`Access denied: path escapes project directory`)
}
const bunFile = Bun.file(full)
if (!(await bunFile.exists())) {
@@ -288,6 +296,13 @@ export namespace File {
ignored = ig.ignores.bind(ig)
}
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory
// TODO: Filesystem.contains is lexical only - symlinks inside the project can escape.
// TODO: On Windows, cross-drive paths bypass this check. Consider realpath canonicalization.
if (!Filesystem.contains(Instance.directory, resolved)) {
throw new Error(`Access denied: path escapes project directory`)
}
const nodes: Node[] = []
for (const entry of await fs.promises
.readdir(resolved, {
+26 -12
View File
@@ -9,9 +9,13 @@ import path from "path"
// @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper"
import { lazy } from "@/util/lazy"
import { withTimeout } from "@/util/timeout"
import type ParcelWatcher from "@parcel/watcher"
import { $ } from "bun"
import { Flag } from "@/flag/flag"
import { readdir } from "fs/promises"
const SUBSCRIBE_TIMEOUT_MS = 10_000
declare const OPENCODE_LIBC: string | undefined
@@ -63,12 +67,16 @@ export namespace FileWatcher {
const cfgIgnores = cfg.watcher?.ignore ?? []
if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
subs.push(
await watcher().subscribe(Instance.directory, subscribe, {
ignore: [...FileIgnore.PATTERNS, ...cfgIgnores],
backend,
}),
)
const pending = watcher().subscribe(Instance.directory, subscribe, {
ignore: [...FileIgnore.PATTERNS, ...cfgIgnores],
backend,
})
const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => {
log.error("failed to subscribe to Instance.directory", { error: err })
pending.then((s) => s.unsubscribe()).catch(() => {})
return undefined
})
if (sub) subs.push(sub)
}
const vcsDir = await $`git rev-parse --git-dir`
@@ -78,12 +86,18 @@ export namespace FileWatcher {
.text()
.then((x) => path.resolve(Instance.worktree, x.trim()))
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
subs.push(
await watcher().subscribe(vcsDir, subscribe, {
ignore: ["hooks", "info", "logs", "objects", "refs", "worktrees", "modules", "lfs"],
backend,
}),
)
const gitDirContents = await readdir(vcsDir).catch(() => [])
const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD")
const pending = watcher().subscribe(vcsDir, subscribe, {
ignore: ignoreList,
backend,
})
const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => {
log.error("failed to subscribe to vcsDir", { error: err })
pending.then((s) => s.unsubscribe()).catch(() => {})
return undefined
})
if (sub) subs.push(sub)
}
return { subs }
+18
View File
@@ -313,3 +313,21 @@ export const gleam: Info = {
return Bun.which("gleam") !== null
},
}
export const shfmt: Info = {
name: "shfmt",
command: ["shfmt", "-w", "$FILE"],
extensions: [".sh", ".bash"],
async enabled() {
return Bun.which("shfmt") !== null
},
}
export const nixfmt: Info = {
name: "nixfmt",
command: ["nixfmt", "$FILE"],
extensions: [".nix"],
async enabled() {
return Bun.which("nixfmt") !== null
},
}
+1
View File
@@ -77,6 +77,7 @@ const cli = yargs(hideBin(process.argv))
})
})
.usage("\n" + UI.logo())
.completion("completion", "generate shell completion script")
.command(AcpCommand)
.command(McpCommand)
.command(TuiThreadCommand)
+18 -10
View File
@@ -166,6 +166,7 @@ export namespace Installation {
export async function latest(installMethod?: Method) {
const detectedMethod = installMethod || (await method())
if (detectedMethod === "brew") {
const formula = await getBrewFormula()
if (formula === "opencode") {
@@ -178,19 +179,26 @@ export namespace Installation {
}
}
const registry = await iife(async () => {
const r = (await $`npm config get registry`.quiet().nothrow().text()).trim()
const reg = r || "https://registry.npmjs.org"
return reg.endsWith("/") ? reg.slice(0, -1) : reg
})
const [major] = VERSION.split(".").map((x) => Number(x))
// const channel = CHANNEL === "latest" ? `latest-${major}` : CHANNEL
const channel = CHANNEL
return fetch(`${registry}/opencode-ai/${channel}`)
if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
const registry = await iife(async () => {
const r = (await $`npm config get registry`.quiet().nothrow().text()).trim()
const reg = r || "https://registry.npmjs.org"
return reg.endsWith("/") ? reg.slice(0, -1) : reg
})
const channel = CHANNEL
return fetch(`${registry}/opencode-ai/${channel}`)
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.version)
}
return fetch("https://api.github.com/repos/sst/opencode/releases/latest")
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.version)
.then((data: any) => data.tag_name.replace(/^v/, ""))
}
}
+1
View File
@@ -39,6 +39,7 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
".hbs": "handlebars",
".handlebars": "handlebars",
".hs": "haskell",
".lhs": "haskell",
".html": "html",
".htm": "html",
".ini": "ini",
+23 -4
View File
@@ -196,13 +196,14 @@ export namespace LSPServer {
}
await fs.rename(extractedPath, finalPath)
await $`npm install`.cwd(finalPath).quiet()
await $`npm run compile`.cwd(finalPath).quiet()
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
await $`${npmCmd} install`.cwd(finalPath).quiet()
await $`${npmCmd} run compile`.cwd(finalPath).quiet()
log.info("installed VS Code ESLint server", { serverPath })
}
const proc = spawn(BunProc.which(), ["--max-old-space-size=8192", serverPath, "--stdio"], {
const proc = spawn(BunProc.which(), [serverPath, "--stdio"], {
cwd: root,
env: {
...process.env,
@@ -1175,7 +1176,7 @@ export namespace LSPServer {
case "linux":
return "config_linux"
case "win32":
return "config_windows"
return "config_win"
default:
return "config_linux"
}
@@ -1892,4 +1893,22 @@ export namespace LSPServer {
}
},
}
export const HLS: Info = {
id: "haskell-language-server",
extensions: [".hs", ".lhs"],
root: NearestRoot(["stack.yaml", "cabal.project", "hie.yaml", "*.cabal"]),
async spawn(root) {
const bin = Bun.which("haskell-language-server-wrapper")
if (!bin) {
log.info("haskell-language-server-wrapper not found, please install haskell-language-server")
return
}
return {
process: spawn(bin, ["--lsp"], {
cwd: root,
}),
}
},
}
}
+3
View File
@@ -308,10 +308,12 @@ export namespace MCP {
if (mcp.type === "local") {
const [cmd, ...args] = mcp.command
const cwd = Instance.directory
const transport = new StdioClientTransport({
stderr: "ignore",
command: cmd,
args,
cwd,
env: {
...process.env,
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
@@ -334,6 +336,7 @@ export namespace MCP {
log.error("local mcp startup failed", {
key,
command: mcp.command,
cwd,
error: error instanceof Error ? error.message : String(error),
})
status = {
+11
View File
@@ -86,6 +86,17 @@ export namespace Permission {
return state().pending
}
export function list() {
const { pending } = state()
const result: Info[] = []
for (const items of Object.values(pending)) {
for (const item of Object.values(items)) {
result.push(item.info)
}
}
return result.sort((a, b) => a.id.localeCompare(b.id))
}
export async function ask(input: {
type: Info["type"]
title: Info["title"]
+24 -9
View File
@@ -165,29 +165,44 @@ export namespace Provider {
}
},
"amazon-bedrock": async () => {
const [awsProfile, awsAccessKeyId, awsBearerToken, awsRegion] = await Promise.all([
Env.get("AWS_PROFILE"),
Env.get("AWS_ACCESS_KEY_ID"),
Env.get("AWS_BEARER_TOKEN_BEDROCK"),
Env.get("AWS_REGION"),
])
const auth = await Auth.get("amazon-bedrock")
const awsProfile = Env.get("AWS_PROFILE")
const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID")
const awsRegion = Env.get("AWS_REGION")
const awsBearerToken = iife(() => {
const envToken = Env.get("AWS_BEARER_TOKEN_BEDROCK")
if (envToken) return envToken
if (auth?.type === "api") {
Env.set("AWS_BEARER_TOKEN_BEDROCK", auth.key)
return auth.key
}
return undefined
})
if (!awsProfile && !awsAccessKeyId && !awsBearerToken) return { autoload: false }
const region = awsRegion ?? "us-east-1"
const defaultRegion = awsRegion ?? "us-east-1"
const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers"))
return {
autoload: true,
options: {
region,
region: defaultRegion,
credentialProvider: fromNodeProviderChain(),
},
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
// Skip region prefixing if model already has global prefix
if (modelID.startsWith("global.")) {
return sdk.languageModel(modelID)
}
// Region resolution precedence (highest to lowest):
// 1. options.region from opencode.json provider config
// 2. defaultRegion from AWS_REGION environment variable
// 3. Default "us-east-1" (baked into defaultRegion)
const region = options?.region ?? defaultRegion
let regionPrefix = region.split("-")[0]
switch (regionPrefix) {
+13 -11
View File
@@ -214,7 +214,7 @@ export namespace ProviderTransform {
const id = model.id.toLowerCase()
if (id.includes("qwen")) return 0.55
if (id.includes("claude")) return undefined
if (id.includes("gemini-3-pro")) return 1.0
if (id.includes("gemini")) return 1.0
if (id.includes("glm-4.6")) return 1.0
if (id.includes("glm-4.7")) return 1.0
if (id.includes("minimax-m2")) return 1.0
@@ -232,12 +232,14 @@ export namespace ProviderTransform {
if (id.includes("m2.1")) return 0.9
return 0.95
}
if (id.includes("gemini")) return 0.95
return undefined
}
export function topK(model: Provider.Model) {
const id = model.id.toLowerCase()
if (id.includes("minimax-m2")) return 20
if (id.includes("gemini")) return 64
return undefined
}
@@ -300,22 +302,22 @@ export namespace ProviderTransform {
}
export function smallOptions(model: Provider.Model) {
const options: Record<string, any> = {}
if (model.providerID === "openai" || model.api.id.includes("gpt-5")) {
if (model.api.id.includes("5.")) {
options["reasoningEffort"] = "low"
} else {
options["reasoningEffort"] = "minimal"
return { reasoningEffort: "low" }
}
return { reasoningEffort: "minimal" }
}
if (model.providerID === "google") {
options["thinkingConfig"] = {
thinkingBudget: 0,
}
return { thinkingConfig: { thinkingBudget: 0 } }
}
return options
if (model.providerID === "openrouter") {
if (model.api.id.includes("google")) {
return { reasoning: { enabled: false } }
}
return { reasoningEffort: "minimal" }
}
return {}
}
export function providerOptions(model: Provider.Model, options: { [x: string]: any }) {
+57
View File
@@ -0,0 +1,57 @@
import { Log } from "@/util/log"
import Bonjour from "bonjour-service"
const log = Log.create({ service: "mdns" })
export namespace MDNS {
let bonjour: Bonjour | undefined
let currentPort: number | undefined
export function publish(port: number, name = "opencode") {
if (currentPort === port) return
if (bonjour) unpublish()
try {
bonjour = new Bonjour()
const service = bonjour.publish({
name,
type: "http",
port,
txt: { path: "/" },
})
service.on("up", () => {
log.info("mDNS service published", { name, port })
})
service.on("error", (err) => {
log.error("mDNS service error", { error: err })
})
currentPort = port
} catch (err) {
log.error("mDNS publish failed", { error: err })
if (bonjour) {
try {
bonjour.destroy()
} catch {}
}
bonjour = undefined
currentPort = undefined
}
}
export function unpublish() {
if (bonjour) {
try {
bonjour.unpublishAll()
bonjour.destroy()
} catch (err) {
log.error("mDNS unpublish failed", { error: err })
}
bonjour = undefined
currentPort = undefined
log.info("mDNS service unpublished")
}
}
}
+52 -5
View File
@@ -45,9 +45,11 @@ import { Snapshot } from "@/snapshot"
import { SessionSummary } from "@/session/summary"
import { SessionStatus } from "@/session/status"
import { upgradeWebSocket, websocket } from "hono/bun"
import type { BunWebSocketData } from "hono/bun"
import { errors } from "./error"
import { Pty } from "@/pty"
import { Installation } from "@/installation"
import { MDNS } from "./mdns"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -1082,6 +1084,8 @@ export namespace Server {
async (c) => {
const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json")
const session = await Session.get(sessionID)
await SessionRevert.cleanup(session)
const msgs = await Session.messages({ sessionID })
let currentAgent = await Agent.defaultAgent()
for (let i = msgs.length - 1; i >= 0; i--) {
@@ -1528,6 +1532,28 @@ export namespace Server {
return c.json(true)
},
)
.get(
"/permission",
describeRoute({
summary: "List pending permissions",
description: "Get all pending permission requests across all sessions.",
operationId: "permission.list",
responses: {
200: {
description: "List of pending permissions",
content: {
"application/json": {
schema: resolver(Permission.Info.array()),
},
},
},
},
}),
async (c) => {
const permissions = Permission.list()
return c.json(permissions)
},
)
.get(
"/command",
describeRoute({
@@ -2623,20 +2649,41 @@ export namespace Server {
return result
}
export function listen(opts: { port: number; hostname: string }) {
export function listen(opts: { port: number; hostname: string; mdns?: boolean }) {
const args = {
hostname: opts.hostname,
idleTimeout: 0,
fetch: App().fetch,
websocket: websocket,
} as const
if (opts.port === 0) {
const tryServe = (port: number) => {
try {
return Bun.serve({ ...args, port: 4096 })
return Bun.serve({ ...args, port })
} catch {
// port 4096 not available, fall through to use port 0
return undefined
}
}
return Bun.serve({ ...args, port: opts.port })
const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port)
if (!server) throw new Error(`Failed to start server on port ${opts.port}`)
const shouldPublishMDNS =
opts.mdns &&
server.port &&
opts.hostname !== "127.0.0.1" &&
opts.hostname !== "localhost" &&
opts.hostname !== "::1"
if (shouldPublishMDNS) {
MDNS.publish(server.port!)
} else if (opts.mdns) {
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
}
const originalStop = server.stop.bind(server)
server.stop = async (closeActiveConnections?: boolean) => {
if (shouldPublishMDNS) MDNS.unpublish()
return originalStop(closeActiveConnections)
}
return server
}
}
+6 -4
View File
@@ -7,13 +7,13 @@ import { Provider } from "../provider/provider"
import { MessageV2 } from "./message-v2"
import z from "zod"
import { SessionPrompt } from "./prompt"
import { Flag } from "../flag/flag"
import { Token } from "../util/token"
import { Log } from "../util/log"
import { SessionProcessor } from "./processor"
import { fn } from "@/util/fn"
import { Agent } from "@/agent/agent"
import { Plugin } from "@/plugin"
import { Config } from "@/config/config"
export namespace SessionCompaction {
const log = Log.create({ service: "session.compaction" })
@@ -27,8 +27,9 @@ export namespace SessionCompaction {
),
}
export function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) return false
export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
const config = await Config.get()
if (config.compaction?.auto === false) return false
const context = input.model.limit.context
if (context === 0) return false
const count = input.tokens.input + input.tokens.cache.read + input.tokens.output
@@ -46,7 +47,8 @@ export namespace SessionCompaction {
// calls. then erases output of previous tool calls. idea is to throw away old
// tool calls that are no longer relevant.
export async function prune(input: { sessionID: string }) {
if (Flag.OPENCODE_DISABLE_PRUNE) return
const config = await Config.get()
if (config.compaction?.prune === false) return
log.info("pruning")
const msgs = await Session.messages({ sessionID: input.sessionID })
let total = 0
+1 -1
View File
@@ -539,7 +539,7 @@ export namespace MessageV2 {
}
}
return convertToModelMessages(result.filter((msg) => msg.parts.length > 0))
return convertToModelMessages(result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")))
}
export const stream = fn(Identifier.schema("session"), async function* (sessionID) {
+1 -1
View File
@@ -459,7 +459,7 @@ export namespace SessionPrompt {
if (
lastFinished &&
lastFinished.summary !== true &&
SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model })
(await SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model }))
) {
await SessionCompaction.create({
sessionID,

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