Compare commits

..
188 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]opencode-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 Engelhardt 504a668a26 Set smallOptions for google models on openrouter (#6362) 2025-12-29 16:01:31 -06:00
ja 5efb1c7b2d feat(highlight): add nix syntax highlighting (#6386) 2025-12-29 15:53:41 -06:00
samcornorandSomair Ansar 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
ja c3d8672753 ci: use env vars for DRY workflow config (#6395) 2025-12-29 15:47:47 -06:00
Shoubhit Dash 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 Bennett 923d114ffa Support different Nix store path prefixes (#6367) 2025-12-29 15:15:42 -06:00
Cole Leavitt b157fd10a7 fix: filter messages with only step-start parts in toModelMessage (#6383) 2025-12-29 14:58:11 -06:00
ja 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
CasualDeveloper 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 Polito b7ce46f7a1 Desktop: Image Preview and Dedupe File Upload (#6372) 2025-12-29 11:22:48 -06:00
Brett HeapbrettheapClaude 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 Cline 68043edae6 ci: changelog script update (#6371) 2025-12-29 11:12:25 -06:00
Frainer Encarnación 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
Matt SilverlockandOpenCode 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 Jiricek 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 Polito 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 Silverlock 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
Alice Alexandra MooreandAiden Cline 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 MorpainAiden 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 Adams 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 Polito 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
Matt SilverlockandOpenCode 0c19b71f42 docs: add plugin configuration documentation (#6301)
Co-authored-by: OpenCode <opencode@example.com>
2025-12-28 13:13:11 -06:00
Mohak S 70fa66397e docs: add opencode-notifier plugin to ecosystem (#6283) 2025-12-28 13:09:38 -06:00
Daniel Polito 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 Durand 29d8557d41 doc: fix typos in various files (#6294) 2025-12-28 13:06:25 -06:00
Didier Durand 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 Silverlock 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
rektideandAiden Cline 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
scarf 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
NindalethandBlack_Fox 2fe7a7f2d3 docs: document attach command (#6254)
Co-authored-by: Black_Fox <radekliska@gmail.com>
2025-12-27 19:11:30 -06:00
Connor Adams 8a2f4ddf70 chore: update INVALID_DIRS to include plural 'skills' directory (#6255) 2025-12-27 19:10:51 -06:00
processtraderopencode-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
ewired 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 Durand e6b9988fa4 doc: fix typos in various files (#6238) 2025-12-27 00:46:06 -06:00
rari404 3c02d5d338 feat: add path traversal protection to File.read and File.list (#5985) 2025-12-26 23:20:07 -06:00
Christopher Ochsenreither bfb9787361 fix: compact command after revert now properly cleans up revert state (#6235) 2025-12-26 22:57:59 -06:00
ja 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 Silverlock 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 Godha 1bcf8d8806 fix: opencode web baseURL error (#6181) 2025-12-26 11:36:31 -06:00
Ariane Emory 25c68c8061 chore: kill the dead Polaris Alpha code (#6193) 2025-12-26 11:32:31 -06:00
ja 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 Walekar 155ba794cf chore: createOpencodeServer expose logLevel (#6202) 2025-12-26 11:09:06 -06:00
Roberto CarvajalandAiden Cline 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
Daniel PolitoandAdam 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
AdamandGithub Action 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 Durand 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
JackNorris 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 Shin 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
Marco d9f0f58277 feat: haskell lsp support (#6141) 2025-12-25 21:04:43 -06:00
opencode effa7b45cf release: v1.0.202
Update Nix Hashes / update (push) Has been cancelled
2025-12-26 02:11:47 +00:00
Adam b307075063 chore: brain icon 2025-12-25 20:06:41 -06:00
Adam aaf9a5d434 fix(desktop): user message display 2025-12-25 19:45:20 -06:00
Adam e9c2f1f3f3 fix(desktop): padding 2025-12-25 19:22:16 -06:00
Adam 7469cba7cf fix(desktop): move session context to top-right 2025-12-25 19:21:04 -06:00
Adam 5420702f69 fix(desktop): missing keybinds in tooltips 2025-12-25 19:07:42 -06:00
Adam 583751ecae fix(desktop): markdown rendering perf 2025-12-25 19:07:42 -06:00
GitHub Action d0a1b5ef96 chore: generate 2025-12-26 01:03:22 +00:00
Adam 42f2bc7199 fix(desktop): can't collapse project with active session 2025-12-25 19:02:43 -06:00
Adam 603dae562a chore(ui): radio group primitive 2025-12-25 18:46:57 -06:00
Adam 650bd76370 feat(desktop): better indicator that session is busy 2025-12-25 14:31:10 -06:00
opencode 8aa3520683 release: v1.0.201
Update Nix Hashes / update (push) Has been cancelled
2025-12-25 14:07:19 +00:00
Adam 5b5b8c57d9 fix(desktop): so many prompt input fixes, merry christmas 2025-12-25 08:04:42 -06:00
GitHub Action f057b22e20 ignore: update download stats 2025-12-25 2025-12-25 12:04:54 +00:00
GitHub Action 388d40e41f chore: generate 2025-12-25 05:59:27 +00:00
Dax Raad f397c92ddf remove list tool 2025-12-25 00:58:47 -05:00
opencode 6f9bea4e1f release: v1.0.200
Update Nix Hashes / update (push) Has been cancelled
2025-12-25 04:39:32 +00:00
Adam 5c49b4cbfc fix(desktop): scroll jank in session turn and review 2025-12-24 22:36:45 -06:00
GitHub Action b746e831e2 chore: generate 2025-12-25 04:17:39 +00:00
Adam 2178deef91 fix(desktop): override agent model 2025-12-24 22:16:58 -06:00
Adam b1d2fb5319 fix(desktop): reconcile session diff updates 2025-12-24 22:16:44 -06:00
198 changed files with 10360 additions and 2044 deletions
+6 -3
View File
@@ -5,6 +5,9 @@ on:
- cron: "0 */12 * * *" - cron: "0 */12 * * *"
workflow_dispatch: workflow_dispatch:
env:
LOOKBACK_HOURS: 4
jobs: jobs:
update-docs: update-docs:
if: github.repository == 'sst/opencode' if: github.repository == 'sst/opencode'
@@ -25,9 +28,9 @@ jobs:
- name: Get recent commits - name: Get recent commits
id: commits id: commits
run: | 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 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 echo "has_commits=false" >> $GITHUB_OUTPUT
else else
echo "has_commits=true" >> $GITHUB_OUTPUT echo "has_commits=true" >> $GITHUB_OUTPUT
@@ -47,7 +50,7 @@ jobs:
model: opencode/gpt-5.2 model: opencode/gpt-5.2
agent: docs agent: docs
prompt: | 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> <recent_commits>
${{ steps.commits.outputs.list }} ${{ 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 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 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. 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. 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: on:
workflow_dispatch: workflow_dispatch:
# release: release:
# types: [published] types: [published]
jobs: jobs:
zed: zed:
@@ -31,4 +31,4 @@ jobs:
run: | run: |
./script/sync-zed.ts ${{ steps.get_tag.outputs.tag }} ./script/sync-zed.ts ${{ steps.get_tag.outputs.tag }}
env: 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 a.out
target target
.scripts .scripts
# Local dev files
opencode-dev
logs/
+1
View File
@@ -1,5 +1,6 @@
--- ---
description: ALWAYS use this when writing docs description: ALWAYS use this when writing docs
color: "#38A3EE"
--- ---
You are an expert technical documentation writer You are an expert technical documentation writer
+1
View File
@@ -2,6 +2,7 @@
mode: primary mode: primary
hidden: true hidden: true
model: opencode/claude-haiku-4-5 model: opencode/claude-haiku-4-5
color: "#44BA81"
tools: tools:
"*": false "*": false
"github-triage": true "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 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: - Core pieces:
- `packages/opencode`: OpenCode core business logic & server. - `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) - `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 - Asks permission before running bash commands
- Ideal for exploring unfamiliar codebases or planning changes - 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. This is used internally and can be invoked using `@general` in messages.
Learn more about [agents](https://opencode.ai/docs/agents). 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 ### 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: It's very similar to Claude Code in terms of capability. Here are the key differences:
+5
View File
@@ -180,3 +180,8 @@
| 2025-12-22 | 1,262,522 (+19,847) | 1,169,121 (+10,212) | 2,431,643 (+30,059) | | 2025-12-22 | 1,262,522 (+19,847) | 1,169,121 (+10,212) | 2,431,643 (+30,059) |
| 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) | | 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-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", "name": "opencode",
"dependencies": { "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", "@aws-sdk/client-s3": "3.933.0",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
@@ -29,7 +22,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.0.199", "version": "1.0.209",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -77,7 +70,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.0.199", "version": "1.0.209",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -105,7 +98,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.0.199", "version": "1.0.209",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -132,7 +125,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.0.199", "version": "1.0.209",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "2.0.0", "@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2", "@ai-sdk/openai": "2.0.2",
@@ -156,7 +149,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.0.199", "version": "1.0.209",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -180,7 +173,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.0.199", "version": "1.0.209",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
@@ -207,7 +200,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.0.199", "version": "1.0.209",
"dependencies": { "dependencies": {
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -236,7 +229,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.0.199", "version": "1.0.209",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -252,7 +245,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.0.199", "version": "1.0.209",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -261,16 +254,23 @@
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.5.1", "@agentclientprotocol/sdk": "0.5.1",
"@ai-sdk/amazon-bedrock": "3.0.57", "@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/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": "2.0.44",
"@ai-sdk/google-vertex": "3.0.81", "@ai-sdk/google-vertex": "3.0.81",
"@ai-sdk/groq": "2.0.33",
"@ai-sdk/mcp": "0.0.8", "@ai-sdk/mcp": "0.0.8",
"@ai-sdk/mistral": "2.0.26", "@ai-sdk/mistral": "2.0.26",
"@ai-sdk/openai": "2.0.71", "@ai-sdk/openai": "2.0.71",
"@ai-sdk/openai-compatible": "1.0.27", "@ai-sdk/openai-compatible": "1.0.27",
"@ai-sdk/perplexity": "2.0.22",
"@ai-sdk/provider": "2.0.0", "@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", "@ai-sdk/xai": "2.0.42",
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
@@ -292,6 +292,7 @@
"@standard-schema/spec": "1.0.0", "@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62", "@zip.js/zip.js": "2.7.62",
"ai": "catalog:", "ai": "catalog:",
"bonjour-service": "1.3.0",
"bun-pty": "0.4.2", "bun-pty": "0.4.2",
"chokidar": "4.0.3", "chokidar": "4.0.3",
"clipboardy": "4.0.0", "clipboardy": "4.0.0",
@@ -346,7 +347,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.0.199", "version": "1.0.209",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"zod": "catalog:", "zod": "catalog:",
@@ -366,7 +367,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.0.199", "version": "1.0.209",
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "0.88.1", "@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
@@ -377,7 +378,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.0.199", "version": "1.0.209",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -390,7 +391,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.0.199", "version": "1.0.209",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -402,8 +403,10 @@
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"katex": "0.16.27",
"luxon": "catalog:", "luxon": "catalog:",
"marked": "catalog:", "marked": "catalog:",
"marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:", "marked-shiki": "catalog:",
"remeda": "catalog:", "remeda": "catalog:",
"shiki": "catalog:", "shiki": "catalog:",
@@ -415,6 +418,7 @@
"@tailwindcss/vite": "catalog:", "@tailwindcss/vite": "catalog:",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/katex": "0.16.7",
"@types/luxon": "catalog:", "@types/luxon": "catalog:",
"tailwindcss": "catalog:", "tailwindcss": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",
@@ -425,7 +429,7 @@
}, },
"packages/util": { "packages/util": {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.0.199", "version": "1.0.209",
"dependencies": { "dependencies": {
"zod": "catalog:", "zod": "catalog:",
}, },
@@ -436,7 +440,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.0.199", "version": "1.0.209",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@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": ["@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=="], "@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=="], "@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=="], "@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=="], "@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/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/luxon": ["@types/luxon@3.7.1", "", {}, "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], "@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=="], "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=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="],
@@ -2247,6 +2257,8 @@
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], "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=="], "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=="], "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=="], "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=="], "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "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": ["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=="], "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=="], "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=="], "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=="], "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=="], "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=="], "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-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=="], "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/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/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/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/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-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/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/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/@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/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/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/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/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=="], "@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=="], "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=="], "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=="], "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=="], "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=="], "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": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1766532406, "lastModified": 1766996594,
"narHash": "sha256-acLU/ag9VEoKkzOD202QASX25nG1eArXg5A0mHjKgxM=", "narHash": "sha256-SosfgQSqVmOkqVgNYJnxW5FvoIQX4grOcpIKNrIwz4o=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "8142186f001295e5a3239f485c8a49bf2de2695a", "rev": "0744ef1b047f07d31d9962d757ffe38ec14a4d41",
"type": "github" "type": "github"
}, },
"original": { "original": {
+10
View File
@@ -155,8 +155,18 @@ if [ -z "$requested_version" ]; then
exit 1 exit 1
fi fi
else else
# Strip leading 'v' if present
requested_version="${requested_version#v}"
url="https://github.com/sst/opencode/releases/download/v${requested_version}/$filename" url="https://github.com/sst/opencode/releases/download/v${requested_version}/$filename"
specific_version=$requested_version 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 fi
print_message() { 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) 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/...") // 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(/(\.\/)+/g, "./")
next = next.replace(/(\.\.\/)+\/?(\/nix\/store[^"']+)/g, "/$2") next = next.replace(
next = next.replace(/(["'])\/{2,}(\/nix\/store[^"']+)(["'])/g, "$1/$2$3") new RegExp(`(\\.\\.\\/)+\\/{1,2}(${nixStorePrefix.replace(/^\//, "").replace(/\//g, "\\/")}[^"']+)`, "g"),
next = next.replace(/(["'])\/\/(nix\/store[^"']+)(["'])/g, "$1/$2$3") "/$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) if (next !== content) fs.writeFileSync(file, next)
-7
View File
@@ -67,13 +67,6 @@
"turbo": "2.5.6" "turbo": "2.5.6"
}, },
"dependencies": { "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", "@aws-sdk/client-s3": "3.933.0",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
+31 -6
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" style="background-color: var(--background-base)">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <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 name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta property="og:image" content="/social-share.png" /> <meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" /> <meta property="twitter:image" content="/social-share.png" />
</head> <!-- Theme preload script - applies cached theme to avoid FOUC -->
<body class="antialiased overscroll-none text-12-regular overflow-hidden"> <script id="oc-theme-preload-script">
<script>
;(function () { ;(function () {
const savedTheme = localStorage.getItem("theme") || "oc-1" var themeId = localStorage.getItem("opencode-theme-id")
document.documentElement.setAttribute("data-theme", savedTheme) 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> </script>
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-screen"></div> <div id="root" class="flex flex-col h-screen"></div>
<script src="/src/entry.tsx" type="module"></script> <script src="/src/entry.tsx" type="module"></script>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.0.199", "version": "1.0.209",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "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 { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { Diff } from "@opencode-ai/ui/diff" import { Diff } from "@opencode-ai/ui/diff"
import { Code } from "@opencode-ai/ui/code" import { Code } from "@opencode-ai/ui/code"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { GlobalSyncProvider } from "@/context/global-sync" import { GlobalSyncProvider } from "@/context/global-sync"
import { LayoutProvider } from "@/context/layout" import { LayoutProvider } from "@/context/layout"
import { GlobalSDKProvider } from "@/context/global-sdk" import { GlobalSDKProvider } from "@/context/global-sdk"
@@ -38,55 +39,57 @@ const url = iife(() => {
if (import.meta.env.DEV) 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://${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() { export function App() {
return ( return (
<MetaProvider> <MetaProvider>
<Font /> <Font />
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}> <ThemeProvider>
<DialogProvider> <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<MarkedProvider> <DialogProvider>
<DiffComponentProvider component={Diff}> <MarkedProvider>
<CodeComponentProvider component={Code}> <DiffComponentProvider component={Diff}>
<GlobalSDKProvider url={url}> <CodeComponentProvider component={Code}>
<GlobalSyncProvider> <GlobalSDKProvider url={url}>
<LayoutProvider> <GlobalSyncProvider>
<NotificationProvider> <LayoutProvider>
<Router <NotificationProvider>
root={(props) => ( <Router
<CommandProvider> root={(props) => (
<Layout>{props.children}</Layout> <CommandProvider>
</CommandProvider> <Layout>{props.children}</Layout>
)} </CommandProvider>
> )}
<Route path="/" component={Home} /> >
<Route path="/:dir" component={DirectoryLayout}> <Route path="/" component={Home} />
<Route path="/" component={() => <Navigate href="session" />} /> <Route path="/:dir" component={DirectoryLayout}>
<Route <Route path="/" component={() => <Navigate href="session" />} />
path="/session/:id?" <Route
component={(p) => ( path="/session/:id?"
<Show when={p.params.id || true} keyed> component={(p) => (
<TerminalProvider> <Show when={p.params.id ?? "new"} keyed>
<PromptProvider> <TerminalProvider>
<Session /> <PromptProvider>
</PromptProvider> <Session />
</TerminalProvider> </PromptProvider>
</Show> </TerminalProvider>
)} </Show>
/> )}
</Route> />
</Router> </Route>
</NotificationProvider> </Router>
</LayoutProvider> </NotificationProvider>
</GlobalSyncProvider> </LayoutProvider>
</GlobalSDKProvider> </GlobalSyncProvider>
</CodeComponentProvider> </GlobalSDKProvider>
</DiffComponentProvider> </CodeComponentProvider>
</MarkedProvider> </DiffComponentProvider>
</DialogProvider> </MarkedProvider>
</ErrorBoundary> </DialogProvider>
</ErrorBoundary>
</ThemeProvider>
</MetaProvider> </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 shareURL = await globalSDK.client.session
.share({ sessionID: session.id, directory: currentDirectory() }) .share({ sessionID: session.id, directory: currentDirectory() })
.then((r) => r.data?.share?.url) .then((r) => r.data?.share?.url)
.catch((e) => {
console.error("Failed to share session", e)
return undefined
})
} }
return shareURL return shareURL
}, },
+279 -107
View File
@@ -82,6 +82,37 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const command = useCommand() const command = useCommand()
let editorRef!: HTMLDivElement let editorRef!: HTMLDivElement
let fileInputRef!: HTMLInputElement 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 sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey())) const tabs = createMemo(() => layout.tabs(sessionKey()))
@@ -103,7 +134,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
imageAttachments: ImageAttachmentPart[] imageAttachments: ImageAttachmentPart[]
mode: "normal" | "shell" mode: "normal" | "shell"
applyingHistory: boolean applyingHistory: boolean
userHasEdited: boolean
}>({ }>({
popover: null, popover: null,
historyIndex: -1, historyIndex: -1,
@@ -113,7 +143,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
imageAttachments: [], imageAttachments: [],
mode: "normal", mode: "normal",
applyingHistory: false, applyingHistory: false,
userHasEdited: false,
}) })
const MAX_HISTORY = 100 const MAX_HISTORY = 100
@@ -150,12 +179,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => { const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
const length = position === "start" ? 0 : promptLength(p) const length = position === "start" ? 0 : promptLength(p)
setStore("applyingHistory", true) setStore("applyingHistory", true)
setStore("userHasEdited", false)
prompt.set(p, length) prompt.set(p, length)
requestAnimationFrame(() => { requestAnimationFrame(() => {
editorRef.focus() editorRef.focus()
setCursorPosition(editorRef, length) setCursorPosition(editorRef, length)
setStore("applyingHistory", false) setStore("applyingHistory", false)
queueScroll()
}) })
} }
@@ -219,6 +248,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
const handlePaste = async (event: ClipboardEvent) => { const handlePaste = async (event: ClipboardEvent) => {
if (!isFocused()) return
const clipboardData = event.clipboardData const clipboardData = event.clipboardData
if (!clipboardData) return if (!clipboardData) return
@@ -241,7 +271,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
addPart({ type: "text", content: plainText, start: 0, end: 0 }) addPart({ type: "text", content: plainText, start: 0, end: 0 })
} }
const handleDragOver = (event: DragEvent) => { const handleGlobalDragOver = (event: DragEvent) => {
event.preventDefault() event.preventDefault()
const hasFiles = event.dataTransfer?.types.includes("Files") const hasFiles = event.dataTransfer?.types.includes("Files")
if (hasFiles) { if (hasFiles) {
@@ -249,15 +279,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
const handleDragLeave = (event: DragEvent) => { const handleGlobalDragLeave = (event: DragEvent) => {
const related = event.relatedTarget as Node | null // relatedTarget is null when leaving the document window
const form = event.currentTarget as HTMLElement if (!event.relatedTarget) {
if (!related || !form.contains(related)) {
setStore("dragging", false) setStore("dragging", false)
} }
} }
const handleDrop = async (event: DragEvent) => { const handleGlobalDrop = async (event: DragEvent) => {
event.preventDefault() event.preventDefault()
setStore("dragging", false) setStore("dragging", false)
@@ -273,17 +302,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onMount(() => { onMount(() => {
editorRef.addEventListener("paste", handlePaste) editorRef.addEventListener("paste", handlePaste)
document.addEventListener("dragover", handleGlobalDragOver)
document.addEventListener("dragleave", handleGlobalDragLeave)
document.addEventListener("drop", handleGlobalDrop)
}) })
onCleanup(() => { onCleanup(() => {
editorRef.removeEventListener("paste", handlePaste) editorRef.removeEventListener("paste", handlePaste)
document.removeEventListener("dragover", handleGlobalDragOver)
document.removeEventListener("dragleave", handleGlobalDragLeave)
document.removeEventListener("drop", handleGlobalDrop)
}) })
createEffect(() => { createEffect(() => {
if (isFocused()) { if (!isFocused()) setStore("popover", null)
handleInput()
} else {
setStore("popover", null)
}
}) })
const handleFileSelect = (path: string | undefined) => { const handleFileSelect = (path: string | undefined) => {
@@ -363,7 +394,26 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
() => prompt.current(), () => prompt.current(),
(currentParts) => { (currentParts) => {
const domParts = parseFromDOM() const domParts = parseFromDOM()
if (isPromptEqual(currentParts, domParts)) return const normalized = Array.from(editorRef.childNodes).every((node) => {
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
const el = node as HTMLElement
if (el.dataset.type === "file") return true
return el.tagName === "BR"
})
if (normalized && isPromptEqual(currentParts, domParts)) return
const selection = window.getSelection() const selection = window.getSelection()
let cursorPosition: number | null = null let cursorPosition: number | null = null
@@ -374,7 +424,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
editorRef.innerHTML = "" editorRef.innerHTML = ""
currentParts.forEach((part) => { currentParts.forEach((part) => {
if (part.type === "text") { if (part.type === "text") {
editorRef.appendChild(document.createTextNode(part.content)) editorRef.appendChild(createTextFragment(part.content))
} else if (part.type === "file") { } else if (part.type === "file") {
const pill = document.createElement("span") const pill = document.createElement("span")
pill.textContent = part.content pill.textContent = part.content
@@ -395,34 +445,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
) )
const parseFromDOM = (): Prompt => { const parseFromDOM = (): Prompt => {
const newParts: Prompt = [] const parts: Prompt = []
let position = 0 let position = 0
let buffer = ""
const pushText = (content: string) => { const flushText = () => {
const content = buffer.replace(/\r\n?/g, "\n").replace(/\u200B/g, "")
buffer = ""
if (!content) return if (!content) return
newParts.push({ type: "text", content, start: position, end: position + content.length }) parts.push({ type: "text", content, start: position, end: position + content.length })
position += content.length position += content.length
} }
const rangeText = (range: Range) => { const pushFile = (file: HTMLElement) => {
const fragment = range.cloneContents()
const container = document.createElement("div")
container.append(fragment)
return container.innerText
}
const files = Array.from(editorRef.querySelectorAll<HTMLElement>("[data-type=file]"))
let last: HTMLElement | undefined
files.forEach((file) => {
const before = document.createRange()
before.selectNodeContents(editorRef)
if (last) before.setStartAfter(last)
before.setEndBefore(file)
pushText(rangeText(before))
const content = file.textContent ?? "" const content = file.textContent ?? ""
newParts.push({ parts.push({
type: "file", type: "file",
path: file.dataset.path!, path: file.dataset.path!,
content, content,
@@ -430,16 +467,44 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
end: position + content.length, end: position + content.length,
}) })
position += content.length position += content.length
last = file }
const visit = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
buffer += node.textContent ?? ""
return
}
if (node.nodeType !== Node.ELEMENT_NODE) return
const el = node as HTMLElement
if (el.dataset.type === "file") {
flushText()
pushFile(el)
return
}
if (el.tagName === "BR") {
buffer += "\n"
return
}
for (const child of Array.from(el.childNodes)) {
visit(child)
}
}
const children = Array.from(editorRef.childNodes)
children.forEach((child, index) => {
const isBlock = child.nodeType === Node.ELEMENT_NODE && ["DIV", "P"].includes((child as HTMLElement).tagName)
visit(child)
if (isBlock && index < children.length - 1) {
buffer += "\n"
}
}) })
const after = document.createRange() flushText()
after.selectNodeContents(editorRef)
if (last) after.setStartAfter(last)
pushText(rangeText(after))
if (newParts.length === 0) newParts.push(...DEFAULT_PROMPT) if (parts.length === 0) parts.push(...DEFAULT_PROMPT)
return newParts return parts
} }
const handleInput = () => { const handleInput = () => {
@@ -452,7 +517,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (shouldReset) { if (shouldReset) {
setStore("popover", null) setStore("popover", null)
setStore("userHasEdited", false)
if (store.historyIndex >= 0 && !store.applyingHistory) { if (store.historyIndex >= 0 && !store.applyingHistory) {
setStore("historyIndex", -1) setStore("historyIndex", -1)
setStore("savedPrompt", null) setStore("savedPrompt", null)
@@ -460,6 +524,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (prompt.dirty()) { if (prompt.dirty()) {
prompt.set(DEFAULT_PROMPT, 0) prompt.set(DEFAULT_PROMPT, 0)
} }
queueScroll()
return return
} }
@@ -487,11 +552,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setStore("savedPrompt", null) setStore("savedPrompt", null)
} }
if (!store.applyingHistory) {
setStore("userHasEdited", true)
}
prompt.set(rawParts, cursorPosition) prompt.set(rawParts, cursorPosition)
queueScroll()
} }
const addPart = (part: ContentPart) => { const addPart = (part: ContentPart) => {
@@ -516,27 +578,40 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const gap = document.createTextNode(" ") const gap = document.createTextNode(" ")
const range = selection.getRangeAt(0) const range = selection.getRangeAt(0)
if (atMatch) { const setEdge = (edge: "start" | "end", offset: number) => {
let runningLength = 0 let remaining = offset
const nodes = Array.from(editorRef.childNodes)
const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null) for (const node of nodes) {
let currentNode = walker.nextNode() const length = getNodeLength(node)
while (currentNode) { const isText = node.nodeType === Node.TEXT_NODE
const textContent = currentNode.textContent || "" const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
if (runningLength + textContent.length >= atMatch.index!) { const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
const localStart = atMatch.index! - runningLength
const localEnd = cursorPosition - runningLength if (isText && remaining <= length) {
if (currentNode === range.startContainer || runningLength + textContent.length >= cursorPosition) { if (edge === "start") range.setStart(node, remaining)
range.setStart(currentNode, localStart) if (edge === "end") range.setEnd(node, remaining)
range.setEnd(currentNode, Math.min(localEnd, textContent.length)) return
break
}
} }
runningLength += textContent.length
currentNode = walker.nextNode() 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)
if (edge === "end" && remaining > 0) range.setEndAfter(node)
return
}
remaining -= length
} }
} }
if (atMatch) {
const start = atMatch.index ?? cursorPosition - atMatch[0].length
setEdge("start", start)
setEdge("end", cursorPosition)
}
range.deleteContents() range.deleteContents()
range.insertNode(gap) range.insertNode(gap)
range.insertNode(pill) range.insertNode(pill)
@@ -545,11 +620,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
selection.removeAllRanges() selection.removeAllRanges()
selection.addRange(range) selection.addRange(range)
} else if (part.type === "text") { } else if (part.type === "text") {
const textNode = document.createTextNode(part.content)
const range = selection.getRangeAt(0) const range = selection.getRangeAt(0)
const fragment = createTextFragment(part.content)
const last = fragment.lastChild
range.deleteContents() range.deleteContents()
range.insertNode(textNode) range.insertNode(fragment)
range.setStartAfter(textNode) 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) range.collapse(true)
selection.removeAllRanges() selection.removeAllRanges()
selection.addRange(range) selection.addRange(range)
@@ -560,9 +649,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
const abort = () => const abort = () =>
sdk.client.session.abort({ sdk.client.session
sessionID: params.id!, .abort({
}) sessionID: params.id!,
})
.catch(() => {})
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
const text = prompt const text = prompt
@@ -584,8 +675,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
const navigateHistory = (direction: "up" | "down") => { const navigateHistory = (direction: "up" | "down") => {
if (store.userHasEdited) return false
const entries = store.mode === "shell" ? shellHistory.entries : history.entries const entries = store.mode === "shell" ? shellHistory.entries : history.entries
const current = store.historyIndex const current = store.historyIndex
@@ -628,6 +717,24 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
const handleKeyDown = (event: KeyboardEvent) => { 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") { if (event.key === "!" && store.mode === "normal") {
const cursorPosition = getCursorPosition(editorRef) const cursorPosition = getCursorPosition(editorRef)
if (cursorPosition === 0) { if (cursorPosition === 0) {
@@ -668,7 +775,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const cursorPosition = getCursorPosition(editorRef) const cursorPosition = getCursorPosition(editorRef)
const textLength = promptLength(prompt.current()) 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 isEmpty = textContent.trim() === "" || textLength <= 1
const hasNewlines = textContent.includes("\n") const hasNewlines = textContent.includes("\n")
const inHistory = store.historyIndex >= 0 const inHistory = store.historyIndex >= 0
@@ -692,6 +802,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return return
} }
if (event.key === "Enter" && event.shiftKey) {
addPart({ type: "text", content: "\n", start: 0, end: 0 })
event.preventDefault()
return
}
if (event.key === "Enter" && !event.shiftKey) { if (event.key === "Enter" && !event.shiftKey) {
handleSubmit(event) handleSubmit(event)
} }
@@ -717,7 +832,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
addToHistory(currentPrompt, store.mode) addToHistory(currentPrompt, store.mode)
setStore("historyIndex", -1) setStore("historyIndex", -1)
setStore("savedPrompt", null) setStore("savedPrompt", null)
setStore("userHasEdited", false)
let existing = info() let existing = info()
if (!existing) { if (!existing) {
@@ -777,12 +891,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const agent = local.agent.current()!.name const agent = local.agent.current()!.name
if (isShellMode) { if (isShellMode) {
sdk.client.session.shell({ sdk.client.session
sessionID: existing.id, .shell({
agent, sessionID: existing.id,
model, agent,
command: text, model,
}) command: text,
})
.catch((e) => {
console.error("Failed to send shell command", e)
})
return return
} }
@@ -791,13 +909,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const commandName = cmdName.slice(1) const commandName = cmdName.slice(1)
const customCommand = sync.data.command.find((c) => c.name === commandName) const customCommand = sync.data.command.find((c) => c.name === commandName)
if (customCommand) { if (customCommand) {
sdk.client.session.command({ sdk.client.session
sessionID: existing.id, .command({
command: commandName, sessionID: existing.id,
arguments: args.join(" "), command: commandName,
agent, arguments: args.join(" "),
model: `${model.providerID}/${model.modelID}`, agent,
}) model: `${model.providerID}/${model.modelID}`,
})
.catch((e) => {
console.error("Failed to send command", e)
})
return return
} }
} }
@@ -823,13 +945,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
model, model,
}) })
sdk.client.session.prompt({ sdk.client.session
sessionID: existing.id, .prompt({
agent, sessionID: existing.id,
model, agent,
messageID, model,
parts: requestParts, messageID,
}) parts: requestParts,
})
.catch((e) => {
console.error("Failed to send prompt", e)
})
} }
return ( return (
@@ -904,9 +1030,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Show> </Show>
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
classList={{ classList={{
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true, "bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
"rounded-md overflow-clip focus-within:shadow-xs-border": true, "rounded-md overflow-clip focus-within:shadow-xs-border": true,
@@ -956,7 +1079,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</For> </For>
</div> </div>
</Show> </Show>
<div class="relative max-h-[240px] overflow-y-auto"> <div class="relative max-h-[240px] overflow-y-auto" ref={(el) => (scrollRef = el)}>
<div <div
data-component="prompt-input" data-component="prompt-input"
ref={(el) => { ref={(el) => {
@@ -967,18 +1090,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onInput={handleInput} onInput={handleInput}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
classList={{ classList={{
"w-full px-5 py-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, "w-full px-5 py-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&>[data-type=file]]:text-icon-info-active": true, "[&_[data-type=file]]:text-icon-info-active": true,
"font-mono!": store.mode === "shell", "font-mono!": store.mode === "shell",
}} }}
/> />
<Show when={!prompt.dirty() && store.imageAttachments.length === 0}> <Show when={!prompt.dirty() && store.imageAttachments.length === 0}>
<div class="absolute top-0 inset-x-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"> <div class="absolute top-0 inset-x-0 px-5 py-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
{store.mode === "shell" {store.mode === "shell"
? "Enter shell command..." ? "Enter shell command..."
: `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`} : `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
</div> </div>
</Show> </Show>
<div class="absolute top-4.5 right-4">
<SessionContextUsage />
</div>
</div> </div>
<div class="relative p-3 flex items-center justify-between"> <div class="relative p-3 flex items-center justify-between">
<div class="flex items-center justify-start gap-1"> <div class="flex items-center justify-start gap-1">
@@ -1035,7 +1161,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Tooltip> </Tooltip>
</Match> </Match>
</Switch> </Switch>
<SessionContextUsage />
</div> </div>
<div class="flex items-center gap-1 absolute right-2 bottom-2"> <div class="flex items-center gap-1 absolute right-2 bottom-2">
<input <input
@@ -1095,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 { function getCursorPosition(parent: HTMLElement): number {
const selection = window.getSelection() const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return 0 if (!selection || selection.rangeCount === 0) return 0
const range = selection.getRangeAt(0) const range = selection.getRangeAt(0)
if (!parent.contains(range.startContainer)) return 0
const preCaretRange = range.cloneRange() const preCaretRange = range.cloneRange()
preCaretRange.selectNodeContents(parent) preCaretRange.selectNodeContents(parent)
preCaretRange.setEnd(range.startContainer, range.startOffset) preCaretRange.setEnd(range.startContainer, range.startOffset)
return preCaretRange.toString().length return getTextLength(preCaretRange.cloneContents())
} }
function setCursorPosition(parent: HTMLElement, position: number) { function setCursorPosition(parent: HTMLElement, position: number) {
let remaining = position let remaining = position
let node = parent.firstChild let node = parent.firstChild
while (node) { while (node) {
const length = node.textContent ? node.textContent.length : 0 const length = getNodeLength(node)
const isText = node.nodeType === Node.TEXT_NODE const isText = node.nodeType === Node.TEXT_NODE
const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file" 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 (isText && remaining <= length) {
const range = document.createRange() const range = document.createRange()
@@ -1123,10 +1281,24 @@ function setCursorPosition(parent: HTMLElement, position: number) {
return return
} }
if (isFile && remaining <= length) { if ((isFile || isBreak) && remaining <= length) {
const range = document.createRange() const range = document.createRange()
const selection = window.getSelection() 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) range.collapse(true)
selection?.removeAllRanges() selection?.removeAllRanges()
selection?.addRange(range) selection?.addRange(range)
@@ -3,7 +3,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { AssistantMessage } from "@opencode-ai/sdk/v2" import { AssistantMessage } from "@opencode-ai/sdk/v2/client"
export function SessionContextUsage() { export function SessionContextUsage() {
const sync = useSync() const sync = useSync()
@@ -34,28 +34,21 @@ export function SessionContextUsage() {
<Show when={context?.()}> <Show when={context?.()}>
{(ctx) => ( {(ctx) => (
<Tooltip <Tooltip
openDelay={300}
value={ value={
<div class="flex flex-col gap-1 p-2"> <div class="grid grid-cols-2 gap-x-3 gap-y-1">
<div class="flex justify-between gap-4"> <span class="opacity-70 text-right">Tokens</span>
<span class="text-text-weaker">Tokens</span> <span class="text-left">{ctx().tokens}</span>
<span class="text-text-strong">{ctx().tokens}</span> <span class="opacity-70 text-right">Usage</span>
</div> <span class="text-left">{ctx().percentage ?? 0}%</span>
<div class="flex justify-between gap-4"> <span class="opacity-70 text-right">Cost</span>
<span class="text-text-weaker">Usage</span> <span class="text-left">{cost()}</span>
<span class="text-text-strong">{ctx().percentage ?? 0}%</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-text-weaker">Cost</span>
<span class="text-text-strong">{cost()}</span>
</div>
</div> </div>
} }
placement="top" placement="top"
> >
<div class="flex items-center gap-1"> <div class="flex items-center gap-1.5">
<span class="text-12-medium text-text-weak">{`${ctx().percentage ?? 0}%`}</span>
<ProgressCircle size={16} strokeWidth={2} percentage={ctx().percentage ?? 0} /> <ProgressCircle size={16} strokeWidth={2} percentage={ctx().percentage ?? 0} />
{/* <span class="text-12-medium text-text-weak">{`${ctx().percentage ?? 0}%`}</span> */}
</div> </div>
</Tooltip> </Tooltip>
)} )}
@@ -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 { 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 { useSDK } from "@/context/sdk"
import { SerializeAddon } from "@/addons/serialize" import { SerializeAddon } from "@/addons/serialize"
import { LocalPTY } from "@/context/terminal" 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"> { export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY pty: LocalPTY
@@ -12,8 +12,28 @@ export interface TerminalProps extends ComponentProps<"div"> {
onConnectError?: (error: unknown) => void 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) => { export const Terminal = (props: TerminalProps) => {
const sdk = useSDK() const sdk = useSDK()
const theme = useTheme()
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"]) const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
let ws: WebSocket let ws: WebSocket
@@ -22,7 +42,64 @@ export const Terminal = (props: TerminalProps) => {
let serializeAddon: SerializeAddon let serializeAddon: SerializeAddon
let fitAddon: FitAddon let fitAddon: FitAddon
let handleResize: () => void 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 () => { onMount(async () => {
ghostty = await Ghostty.load() ghostty = await Ghostty.load()
@@ -33,23 +110,22 @@ export const Terminal = (props: TerminalProps) => {
fontSize: 14, fontSize: 14,
fontFamily: "IBM Plex Mono, monospace", fontFamily: "IBM Plex Mono, monospace",
allowTransparency: true, allowTransparency: true,
theme: prefersDark() theme: terminalColors(),
? {
background: "#191515",
foreground: "#d4d4d4",
cursor: "#d4d4d4",
}
: {
background: "#fcfcfc",
foreground: "#211e1e",
cursor: "#211e1e",
},
scrollback: 10_000, scrollback: 10_000,
ghostty, ghostty,
}) })
term.attachCustomKeyEventHandler((event) => { 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 // allow for ctrl-` to toggle terminal in parent
if (event.ctrlKey && event.key.toLowerCase() === "`") { if (event.ctrlKey && key === "`") {
event.preventDefault() event.preventDefault()
return true return true
} }
@@ -62,6 +138,8 @@ export const Terminal = (props: TerminalProps) => {
term.loadAddon(fitAddon) term.loadAddon(fitAddon)
term.open(container) term.open(container)
container.addEventListener("pointerdown", handlePointerDown)
focusTerminal()
if (local.pty.buffer) { if (local.pty.buffer) {
if (local.pty.rows && local.pty.cols) { if (local.pty.rows && local.pty.cols) {
@@ -75,20 +153,20 @@ export const Terminal = (props: TerminalProps) => {
fitAddon.fit() fitAddon.fit()
} }
container.focus()
fitAddon.observeResize() fitAddon.observeResize()
handleResize = () => fitAddon.fit() handleResize = () => fitAddon.fit()
window.addEventListener("resize", handleResize) window.addEventListener("resize", handleResize)
term.onResize(async (size) => { term.onResize(async (size) => {
if (ws && ws.readyState === WebSocket.OPEN) { if (ws && ws.readyState === WebSocket.OPEN) {
await sdk.client.pty.update({ await sdk.client.pty
ptyID: local.pty.id, .update({
size: { ptyID: local.pty.id,
cols: size.cols, size: {
rows: size.rows, cols: size.cols,
}, rows: size.rows,
}) },
})
.catch(() => {})
} }
}) })
term.onData((data) => { term.onData((data) => {
@@ -106,13 +184,15 @@ export const Terminal = (props: TerminalProps) => {
// }) // })
ws.addEventListener("open", () => { ws.addEventListener("open", () => {
console.log("WebSocket connected") console.log("WebSocket connected")
sdk.client.pty.update({ sdk.client.pty
ptyID: local.pty.id, .update({
size: { ptyID: local.pty.id,
cols: term.cols, size: {
rows: term.rows, cols: term.cols,
}, rows: term.rows,
}) },
})
.catch(() => {})
}) })
ws.addEventListener("message", (event) => { ws.addEventListener("message", (event) => {
term.write(event.data) term.write(event.data)
@@ -130,6 +210,7 @@ export const Terminal = (props: TerminalProps) => {
if (handleResize) { if (handleResize) {
window.removeEventListener("resize", handleResize) window.removeEventListener("resize", handleResize)
} }
container.removeEventListener("pointerdown", handlePointerDown)
if (serializeAddon && props.onCleanup) { if (serializeAddon && props.onCleanup) {
const buffer = serializeAddon.serialize() const buffer = serializeAddon.serialize()
props.onCleanup({ props.onCleanup({
@@ -149,6 +230,7 @@ export const Terminal = (props: TerminalProps) => {
ref={container} ref={container}
data-component="terminal" data-component="terminal"
data-prevent-autofocus data-prevent-autofocus
style={{ "background-color": terminalColors().background }}
classList={{ classList={{
...(local.classList ?? {}), ...(local.classList ?? {}),
"size-full px-6 py-3 font-mono": true, "size-full px-6 py-3 font-mono": true,
+25 -6
View File
@@ -26,6 +26,7 @@ export interface CommandOption {
suggested?: boolean suggested?: boolean
disabled?: boolean disabled?: boolean
onSelect?: (source?: "palette" | "keybind" | "slash") => void onSelect?: (source?: "palette" | "keybind" | "slash") => void
onHighlight?: () => (() => void) | void
} }
export function parseKeybind(config: string): Keybind[] { export function parseKeybind(config: string): Keybind[] {
@@ -115,6 +116,28 @@ export function formatKeybind(config: string): string {
function DialogCommand(props: { options: CommandOption[] }) { function DialogCommand(props: { options: CommandOption[] }) {
const dialog = useDialog() 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 ( return (
<Dialog title="Commands"> <Dialog title="Commands">
@@ -125,12 +148,8 @@ function DialogCommand(props: { options: CommandOption[] }) {
key={(x) => x?.id} key={(x) => x?.id}
filterKeys={["title", "description", "category"]} filterKeys={["title", "description", "category"]}
groupBy={(x) => x.category ?? ""} groupBy={(x) => x.category ?? ""}
onSelect={(option) => { onMove={handleMove}
if (option) { onSelect={handleSelect}
dialog.close()
option.onSelect?.("palette")
}
}}
> >
{(option) => ( {(option) => (
<div class="w-full flex items-center justify-between gap-4"> <div class="w-full flex items-center justify-between gap-4">
+105 -10
View File
@@ -12,6 +12,10 @@ import {
type ProviderListResponse, type ProviderListResponse,
type ProviderAuthResponse, type ProviderAuthResponse,
type Command, type Command,
type McpStatus,
type LspStatus,
type VcsInfo,
type Permission,
createOpencodeClient, createOpencodeClient,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store" 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 { retry } from "@opencode-ai/util/retry"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { ErrorPage, type InitError } from "../pages/error" 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 { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
@@ -41,6 +45,14 @@ type State = {
todo: { todo: {
[sessionID: string]: Todo[] [sessionID: string]: Todo[]
} }
permission: {
[sessionID: string]: Permission[]
}
mcp: {
[name: string]: McpStatus
}
lsp: LspStatus[]
vcs: VcsInfo | undefined
limit: number limit: number
message: { message: {
[sessionID: string]: Message[] [sessionID: string]: Message[]
@@ -59,21 +71,19 @@ function createGlobalSync() {
project: Project[] project: Project[]
provider: ProviderListResponse provider: ProviderListResponse
provider_auth: ProviderAuthResponse provider_auth: ProviderAuthResponse
children: Record<string, State>
}>({ }>({
ready: false, ready: false,
path: { state: "", config: "", worktree: "", directory: "", home: "" }, path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: [], project: [],
provider: { all: [], connected: [], default: {} }, provider: { all: [], connected: [], default: {} },
provider_auth: {}, provider_auth: {},
children: {},
}) })
const children: Record<string, ReturnType<typeof createStore<State>>> = {} const children: Record<string, ReturnType<typeof createStore<State>>> = {}
function child(directory: string) { function child(directory: string) {
if (!directory) console.error("No directory provided") if (!directory) console.error("No directory provided")
if (!children[directory]) { if (!children[directory]) {
setGlobalStore("children", directory, { children[directory] = createStore<State>({
project: "", project: "",
provider: { all: [], connected: [], default: {} }, provider: { all: [], connected: [], default: {} },
config: {}, config: {},
@@ -85,11 +95,14 @@ function createGlobalSync() {
session_status: {}, session_status: {},
session_diff: {}, session_diff: {},
todo: {}, todo: {},
permission: {},
mcp: {},
lsp: [],
vcs: undefined,
limit: 5, limit: 5,
message: {}, message: {},
part: {}, part: {},
}) })
children[directory] = createStore(globalStore.children[directory])
bootstrapInstance(directory) bootstrapInstance(directory)
} }
return children[directory] return children[directory]
@@ -111,7 +124,7 @@ function createGlobalSync() {
const updated = new Date(s.time.updated).getTime() const updated = new Date(s.time.updated).getTime()
return updated > fourHoursAgo return updated > fourHoursAgo
}) })
setStore("session", sessions) setStore("session", reconcile(sessions, { key: "id" }))
}) })
.catch((err) => { .catch((err) => {
console.error("Failed to load sessions", err) console.error("Failed to load sessions", err)
@@ -122,7 +135,7 @@ function createGlobalSync() {
async function bootstrapInstance(directory: string) { async function bootstrapInstance(directory: string) {
if (!directory) return if (!directory) return
const [, setStore] = child(directory) const [store, setStore] = child(directory)
const sdk = createOpencodeClient({ const sdk = createOpencodeClient({
baseUrl: globalSDK.url, baseUrl: globalSDK.url,
directory, directory,
@@ -149,6 +162,38 @@ function createGlobalSync() {
session: () => loadSessions(directory), session: () => loadSessions(directory),
status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)), status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
config: () => sdk.config.get().then((x) => setStore("config", 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)))) await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
.then(() => setStore("ready", true)) .then(() => setStore("ready", true))
@@ -215,13 +260,13 @@ function createGlobalSync() {
break break
} }
case "session.diff": case "session.diff":
setStore("session_diff", event.properties.sessionID, event.properties.diff) setStore("session_diff", event.properties.sessionID, reconcile(event.properties.diff, { key: "file" }))
break break
case "todo.updated": case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos) setStore("todo", event.properties.sessionID, reconcile(event.properties.todos, { key: "id" }))
break break
case "session.status": { case "session.status": {
setStore("session_status", event.properties.sessionID, event.properties.status) setStore("session_status", event.properties.sessionID, reconcile(event.properties.status))
break break
} }
case "message.updated": { case "message.updated": {
@@ -295,6 +340,56 @@ function createGlobalSync() {
} }
break 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, ...project,
...(metadata ?? {}), ...(metadata ?? {}),
icon: { url: metadata?.icon?.url, color: metadata?.icon?.color },
}, },
] ]
} }
+15 -30
View File
@@ -1,5 +1,5 @@
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo } from "solid-js" import { batch, createMemo } from "solid-js"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda" import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2" import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
@@ -62,24 +62,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
} }
// Automatically update model when agent changes
createEffect(() => {
const value = agent.current()
if (value.model) {
if (isModelValid(value.model))
model.set({
providerID: value.model.providerID,
modelID: value.model.modelID,
})
// else
// toast.show({
// type: "warning",
// message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
// duration: 3000,
// })
}
})
const agent = (() => { const agent = (() => {
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
@@ -395,17 +377,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
const list = async (path: string) => { const list = async (path: string) => {
return sdk.client.file.list({ path: path + "/" }).then((x) => { return sdk.client.file
setStore( .list({ path: path + "/" })
"node", .then((x) => {
produce((draft) => { setStore(
x.data!.forEach((node) => { "node",
if (node.path in draft) return produce((draft) => {
draft[node.path] = node 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!) 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 { batch, createMemo } from "solid-js"
import { createMemo } from "solid-js" import { produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry" import { retry } from "@opencode-ai/util/retry"
import { createSimpleContext } from "@opencode-ai/ui/context" 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) const result = Binary.search(messages, input.messageID, (m) => m.id)
messages.splice(result.index, 0, message) 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.todo({ sessionID })),
retry(() => sdk.client.session.diff({ sessionID })), retry(() => sdk.client.session.diff({ sessionID })),
]) ])
setStore(
produce((draft) => { batch(() => {
const match = Binary.search(draft.session, sessionID, (s) => s.id) setStore(
if (match.found) draft.session[match.index] = session.data! "session",
if (!match.found) draft.session.splice(match.index, 0, session.data!) produce((draft) => {
draft.todo[sessionID] = todo.data ?? [] const match = Binary.search(draft, sessionID, (s) => s.id)
draft.message[sessionID] = messages if (match.found) {
.data!.map((x) => x.info) draft[match.index] = session.data!
.slice() return
.sort((a, b) => a.id.localeCompare(b.id)) }
for (const message of messages.data!) { draft.splice(match.index, 0, session.data!)
draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)) }),
} )
draft.session_diff[sessionID] = diff.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) => { fetch: async (count = 10) => {
setStore("limit", (x) => x + count) setStore("limit", (x) => x + count)
@@ -91,7 +115,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.slice() .slice()
.sort((a, b) => a.id.localeCompare(b.id)) .sort((a, b) => a.id.localeCompare(b.id))
.slice(0, store.limit) .slice(0, store.limit)
setStore("session", sessions) setStore("session", reconcile(sessions, { key: "id" }))
}) })
}, },
more: createMemo(() => store.session.length >= store.limit), 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)), all: createMemo(() => Object.values(store.all)),
active: createMemo(() => store.active), active: createMemo(() => store.active),
new() { new() {
sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => { sdk.client.pty
const id = pty.data?.id .create({ title: `Terminal ${store.all.length + 1}` })
if (!id) return .then((pty) => {
setStore("all", [ const id = pty.data?.id
...store.all, if (!id) return
{ setStore("all", [
id, ...store.all,
title: pty.data?.title ?? "Terminal", {
}, id,
]) title: pty.data?.title ?? "Terminal",
setStore("active", id) },
}) ])
setStore("active", id)
})
.catch((e) => {
console.error("Failed to create terminal", e)
})
}, },
update(pty: Partial<LocalPTY> & { id: string }) { update(pty: Partial<LocalPTY> & { id: string }) {
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
sdk.client.pty.update({ sdk.client.pty
ptyID: pty.id, .update({
title: pty.title, ptyID: pty.id,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, 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) { async clone(id: string) {
const index = store.all.findIndex((x) => x.id === id) const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index] const pty = store.all[index]
if (!pty) return if (!pty) return
const clone = await sdk.client.pty.create({ const clone = await sdk.client.pty
title: pty.title, .create({
}) title: pty.title,
if (!clone.data) return })
.catch((e) => {
console.error("Failed to clone terminal", e)
return undefined
})
if (!clone?.data) return
setStore("all", index, { setStore("all", index, {
...pty, ...pty,
...clone.data, ...clone.data,
@@ -88,7 +102,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
setStore("active", previous?.id) 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) { move(id: string, to: number) {
const index = store.all.findIndex((f) => f.id === id) 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 { createMemo, Show, type ParentProps } from "solid-js"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { SDKProvider } from "@/context/sdk" import { SDKProvider, useSDK } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync" import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local" import { LocalProvider } from "@/context/local"
import { PermissionProvider } from "@/context/permission"
import { base64Decode } from "@opencode-ai/util/encode" import { base64Decode } from "@opencode-ai/util/encode"
import { DataProvider } from "@opencode-ai/ui/context" import { DataProvider } from "@opencode-ai/ui/context"
import { iife } from "@opencode-ai/util/iife" import { iife } from "@opencode-ai/util/iife"
@@ -18,10 +19,19 @@ export default function Layout(props: ParentProps) {
<SyncProvider> <SyncProvider>
{iife(() => { {iife(() => {
const sync = useSync() const sync = useSync()
const sdk = useSDK()
const respond = (input: {
sessionID: string
permissionID: string
response: "once" | "always" | "reject"
}) => sdk.client.permission.respond(input)
return ( return (
<DataProvider data={sync.data} directory={directory()}> <PermissionProvider permissions={sync.data.permission} onRespond={respond}>
<LocalProvider>{props.children}</LocalProvider> <DataProvider data={sync.data} directory={directory()} onPermissionRespond={respond}>
</DataProvider> <LocalProvider>{props.children}</LocalProvider>
</DataProvider>
</PermissionProvider>
) )
})} })}
</SyncProvider> </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 { function formatInitError(error: InitError): string {
const data = error.data const data = error.data
switch (error.name) { switch (error.name) {
case "MCPFailed": case "MCPFailed":
return `MCP server "${data.name}" failed. Note, opencode does not support MCP authentication yet.` 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": { case "ProviderModelNotFoundError": {
const { providerID, modelID, suggestions } = data as { const { providerID, modelID, suggestions } = data as {
providerID: string providerID: string
@@ -37,10 +77,14 @@ function formatInitError(error: InitError): string {
`Check your config (opencode.json) provider/model names`, `Check your config (opencode.json) provider/model names`,
].join("\n") ].join("\n")
} }
case "ProviderInitError": case "ProviderInitError": {
return `Failed to initialize provider "${data.providerID}". Check credentials and configuration.` const providerID = typeof data.providerID === "string" ? data.providerID : "unknown"
case "ConfigJsonError": return `Failed to initialize provider "${providerID}". Check credentials and configuration.`
return `Config file at ${data.path} is not valid JSON(C)` + (data.message ? `: ${data.message}` : "") }
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": 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.` 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": case "ConfigFrontmatterError":
@@ -51,14 +95,14 @@ function formatInitError(error: InitError): string {
(issue: { message: string; path: string[] }) => "↳ " + issue.message + " " + issue.path.join("."), (issue: { message: string; path: string[] }) => "↳ " + issue.message + " " + issue.path.join("."),
) )
: [] : []
return [`Config file at ${data.path} is invalid` + (data.message ? `: ${data.message}` : ""), ...issues].join( const message = typeof data.message === "string" ? data.message : ""
"\n", return [`Config file at ${data.path} is invalid` + (message ? `: ${message}` : ""), ...issues].join("\n")
)
} }
case "UnknownError": case "UnknownError":
return String(data.message) return typeof data.message === "string" ? data.message : safeJson(data)
default: 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) const message = formatInitError(error)
if (depth > 0 && parentMessage === message) return "" if (depth > 0 && parentMessage === message) return ""
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : "" const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
return indent + message return indent + `${error.name}\n${message}`
} }
if (error instanceof Error) { if (error instanceof Error) {
@@ -77,15 +121,34 @@ function formatErrorChain(error: unknown, depth = 0, parentMessage?: string): st
const parts: string[] = [] const parts: string[] = []
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : "" const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
if (!isDuplicate) { const header = `${error.name}${error.message ? `: ${error.message}` : ""}`
// Stack already includes error name and message, so prefer it const stack = error.stack?.trim()
parts.push(indent + (error.stack ?? `${error.name}: ${error.message}`))
} else if (error.stack) { if (stack) {
// Duplicate message - only show the stack trace lines (skip message) const startsWithHeader = stack.startsWith(header)
const trace = error.stack.split("\n").slice(1).join("\n").trim()
if (trace) { if (isDuplicate && startsWithHeader) {
parts.push(trace) 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) { 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` : "" 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 { function formatError(error: unknown): string {
@@ -131,7 +194,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
readOnly readOnly
copyable copyable
multiline 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" label="Error Details"
hideLabel hideLabel
/> />
+251 -62
View File
@@ -9,6 +9,7 @@ import {
ParentProps, ParentProps,
Show, Show,
Switch, Switch,
untrack,
type JSX, type JSX,
} from "solid-js" } from "solid-js"
import { DateTime } from "luxon" import { DateTime } from "luxon"
@@ -40,14 +41,16 @@ import {
} from "@thisbeyond/solid-dnd" } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useProviders } from "@/hooks/use-providers" 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 { useGlobalSDK } from "@/context/global-sdk"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { Header } from "@/components/header" import { Header } from "@/components/header"
import { useDialog } from "@opencode-ai/ui/context/dialog" 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 { 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" import { ConstrainDragXAxis } from "@/utils/solid-dnd"
export default function Layout(props: ParentProps) { export default function Layout(props: ParentProps) {
@@ -88,6 +91,41 @@ export default function Layout(props: ParentProps) {
const providers = useProviders() const providers = useProviders()
const dialog = useDialog() const dialog = useDialog()
const command = useCommand() 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 () => { onMount(async () => {
if (platform.checkUpdate && platform.update && platform.restart) { if (platform.checkUpdate && platform.update && platform.restart) {
@@ -116,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) { function sortSessions(a: Session, b: Session) {
const now = Date.now() const now = Date.now()
const oneMinuteAgo = now - 60 * 1000 const oneMinuteAgo = now - 60 * 1000
@@ -220,57 +323,102 @@ export default function Layout(props: ParentProps) {
} }
} }
command.register(() => [ command.register(() => {
{ const commands: CommandOption[] = [
id: "sidebar.toggle", {
title: "Toggle sidebar", id: "sidebar.toggle",
category: "View", title: "Toggle sidebar",
keybind: "mod+b", category: "View",
onSelect: () => layout.sidebar.toggle(), 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)
}, },
}, ...(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() { function connectProvider() {
dialog.show(() => <DialogSelectProvider />) dialog.show(() => <DialogSelectProvider />)
@@ -323,7 +471,7 @@ export default function Layout(props: ParentProps) {
const id = params.id const id = params.id
setStore("lastSession", directory, id) setStore("lastSession", directory, id)
notification.session.markViewed(id) notification.session.markViewed(id)
layout.projects.expand(directory) untrack(() => layout.projects.expand(directory))
requestAnimationFrame(() => scrollToSession(id)) requestAnimationFrame(() => scrollToSession(id))
}) })
@@ -375,7 +523,7 @@ export default function Layout(props: ParentProps) {
const notification = useNotification() const notification = useNotification()
const notifications = createMemo(() => notification.project.unseen(props.project.worktree)) const notifications = createMemo(() => notification.project.unseen(props.project.worktree))
const hasError = createMemo(() => notifications().some((n) => n.type === "error")) 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 mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)"
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
@@ -411,7 +559,7 @@ export default function Layout(props: ParentProps) {
} }
const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => { 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 ?? "")) const current = createMemo(() => base64Decode(params.dir ?? ""))
return ( return (
<Switch> <Switch>
@@ -453,8 +601,20 @@ export default function Layout(props: ParentProps) {
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated)) const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
const notifications = createMemo(() => notification.session.unseen(props.session.id)) const notifications = createMemo(() => notification.session.unseen(props.session.id))
const hasError = createMemo(() => notifications().some((n) => n.type === "error")) 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(() => { const isWorking = createMemo(() => {
if (props.session.id === params.id) return false 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] const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id]
return status?.type === "busy" || status?.type === "retry" return status?.type === "busy" || status?.type === "retry"
}) })
@@ -472,7 +632,12 @@ export default function Layout(props: ParentProps) {
class="flex flex-col min-w-0 text-left w-full focus:outline-none" class="flex flex-col min-w-0 text-left w-full focus:outline-none"
> >
<div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7"> <div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate"> <span
classList={{
"text-14-regular text-text-strong overflow-hidden text-ellipsis truncate": true,
"animate-pulse": isWorking(),
}}
>
{props.session.title} {props.session.title}
</span> </span>
<div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden"> <div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
@@ -480,6 +645,9 @@ export default function Layout(props: ParentProps) {
<Match when={isWorking()}> <Match when={isWorking()}>
<Spinner class="size-2.5 mr-0.5" /> <Spinner class="size-2.5 mr-0.5" />
</Match> </Match>
<Match when={hasPermissions()}>
<div class="size-1.5 mr-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={hasError()}> <Match when={hasError()}>
<div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" /> <div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
</Match> </Match>
@@ -513,7 +681,15 @@ export default function Layout(props: ParentProps) {
</A> </A>
</Tooltip> </Tooltip>
<div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1"> <div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
<Tooltip placement={props.mobile ? "bottom" : "right"} value="Archive session"> <Tooltip
placement={props.mobile ? "bottom" : "right"}
value={
<div class="flex items-center gap-2">
<span>Archive session</span>
<span class="text-icon-base text-12-medium">{command.keybind("session.archive")}</span>
</div>
}
>
<IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} /> <IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
</Tooltip> </Tooltip>
</div> </div>
@@ -526,7 +702,7 @@ export default function Layout(props: ParentProps) {
const sortable = createSortable(props.project.worktree) const sortable = createSortable(props.project.worktree)
const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened()) const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened())
const slug = createMemo(() => base64Encode(props.project.worktree)) 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 [store, setProjectStore] = globalSync.child(props.project.worktree)
const sessions = createMemo(() => store.session.toSorted(sortSessions)) const sessions = createMemo(() => store.session.toSorted(sortSessions))
const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID)) const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
@@ -572,13 +748,26 @@ export default function Layout(props: ParentProps) {
<DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" /> <DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" />
<DropdownMenu.Portal> <DropdownMenu.Portal>
<DropdownMenu.Content> <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.Item onSelect={() => closeProject(props.project.worktree)}>
<DropdownMenu.ItemLabel>Close Project</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>Close project</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Portal> </DropdownMenu.Portal>
</DropdownMenu> </DropdownMenu>
<Tooltip placement="top" value="New session"> <Tooltip
placement="top"
value={
<div class="flex items-center gap-2">
<span>New session</span>
<span class="text-icon-base text-12-medium">{command.keybind("session.new")}</span>
</div>
}
>
<IconButton as={A} href={`${slug()}/session`} icon="plus-small" variant="ghost" /> <IconButton as={A} href={`${slug()}/session`} icon="plus-small" variant="ghost" />
</Tooltip> </Tooltip>
</div> </div>
@@ -727,7 +916,7 @@ export default function Layout(props: ParentProps) {
</div> </div>
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3"> <div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
<Switch> <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="rounded-md bg-background-stronger shadow-xs-border-base">
<div class="p-3 flex flex-col gap-2"> <div class="p-3 flex flex-col gap-2">
<div class="text-12-medium text-text-strong">Getting started</div> <div class="text-12-medium text-text-strong">Getting started</div>
@@ -746,7 +935,7 @@ export default function Layout(props: ParentProps) {
</Tooltip> </Tooltip>
</div> </div>
</Match> </Match>
<Match when={true}> <Match when={providers.all().length > 0}>
<Tooltip placement="right" value="Connect provider" inactive={expanded()}> <Tooltip placement="right" value="Connect provider" inactive={expanded()}>
<Button <Button
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2" 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 { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file" import { DialogSelectFile } from "@/components/dialog-select-file"
import { DialogSelectModel } from "@/components/dialog-select-model" import { DialogSelectModel } from "@/components/dialog-select-model"
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { UserMessage } from "@opencode-ai/sdk/v2" import { UserMessage } from "@opencode-ai/sdk/v2"
@@ -56,6 +57,17 @@ import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt" import { usePrompt } from "@/context/prompt"
import { extractPromptFromParts } from "@/utils/prompt" import { extractPromptFromParts } from "@/utils/prompt"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" 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() { export default function Page() {
const layout = useLayout() const layout = useLayout()
@@ -70,22 +82,40 @@ export default function Page() {
const sdk = useSDK() const sdk = useSDK()
const prompt = usePrompt() const prompt = usePrompt()
const permission = usePermission()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey())) const tabs = createMemo(() => layout.tabs(sessionKey()))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const revertMessageID = createMemo(() => info()?.revert?.messageID) const revertMessageID = createMemo(() => info()?.revert?.messageID)
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const userMessages = createMemo(() => const emptyUserMessages: UserMessage[] = []
messages() const userMessages = createMemo(
.filter((m) => m.role === "user") () => messages().filter((m) => m.role === "user") as UserMessage[],
.sort((a, b) => a.id.localeCompare(b.id)), 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({ const [store, setStore] = createStore({
clickTimer: undefined as number | undefined, clickTimer: undefined as number | undefined,
@@ -155,16 +185,37 @@ export default function Page() {
), ),
) )
createEffect(() => { const idle = { type: "idle" as const }
params.id
const status = sync.data.session_status[params.id ?? ""] ?? { type: "idle" } createEffect(
batch(() => { on(
setStore("userInteracted", false) () => params.id,
setStore("stepsExpanded", status.type !== "idle") (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) const working = createMemo(() => status().type !== "idle" && activeMessage()?.id === lastUserMessage()?.id)
createRenderEffect((prev) => { createRenderEffect((prev) => {
@@ -274,6 +325,15 @@ export default function Page() {
slash: "model", slash: "model",
onSelect: () => dialog.show(() => <DialogSelectModel />), 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", id: "agent.cycle",
title: "Cycle agent", title: "Cycle agent",
@@ -291,6 +351,22 @@ export default function Page() {
keybind: "shift+mod+.", keybind: "shift+mod+.",
onSelect: () => local.agent.move(-1), 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", id: "session.undo",
title: "Undo", title: "Undo",
@@ -562,6 +638,7 @@ export default function Page() {
<SessionTurn <SessionTurn
sessionID={params.id!} sessionID={params.id!}
messageID={message.id} messageID={message.id}
lastUserMessageID={lastUserMessage()?.id}
stepsExpanded={store.mobileStepsExpanded[message.id] ?? false} stepsExpanded={store.mobileStepsExpanded[message.id] ?? false}
onStepsExpandedToggle={() => setStore("mobileStepsExpanded", message.id, (x) => !x)} onStepsExpandedToggle={() => setStore("mobileStepsExpanded", message.id, (x) => !x)}
onUserInteracted={() => setStore("userInteracted", true)} onUserInteracted={() => setStore("userInteracted", true)}
@@ -618,6 +695,7 @@ export default function Page() {
<SessionTurn <SessionTurn
sessionID={params.id!} sessionID={params.id!}
messageID={activeMessage()!.id} messageID={activeMessage()!.id}
lastUserMessageID={lastUserMessage()?.id}
stepsExpanded={store.stepsExpanded} stepsExpanded={store.stepsExpanded}
onStepsExpandedToggle={() => setStore("stepsExpanded", (x) => !x)} onStepsExpandedToggle={() => setStore("stepsExpanded", (x) => !x)}
onUserInteracted={() => setStore("userInteracted", true)} onUserInteracted={() => setStore("userInteracted", true)}
@@ -921,6 +999,10 @@ export default function Page() {
</DragDropProvider> </DragDropProvider>
</div> </div>
</Show> </Show>
<StatusBar>
<SessionLspIndicator />
<SessionMcpIndicator />
</StatusBar>
</div> </div>
) )
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.0.199", "version": "1.0.209",
"type": "module", "type": "module",
"scripts": { "scripts": {
"typecheck": "tsgo --noEmit", "typecheck": "tsgo --noEmit",
@@ -5,28 +5,36 @@ import { useAuthSession } from "~/context/auth.session"
export async function GET(input: APIEvent) { export async function GET(input: APIEvent) {
const url = new URL(input.request.url) const url = new URL(input.request.url)
const code = url.searchParams.get("code") try {
if (!code) throw new Error("No code found") const code = url.searchParams.get("code")
const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`) if (!code) throw new Error("No code found")
if (result.err) { const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`)
throw new Error(result.err.message) if (result.err) throw new Error(result.err.message)
} const decoded = AuthClient.decode(result.tokens.access, {} as any)
const decoded = AuthClient.decode(result.tokens.access, {} as any) if (decoded.err) throw new Error(decoded.err.message)
if (decoded.err) throw new Error(decoded.err.message) const session = await useAuthSession()
const session = await useAuthSession() const id = decoded.subject.properties.accountID
const id = decoded.subject.properties.accountID await session.update((value) => {
await session.update((value) => { return {
return { ...value,
...value, account: {
account: { ...value.account,
...value.account, [id]: {
[id]: { id,
id, email: decoded.subject.properties.email,
email: decoded.subject.properties.email, },
}, },
}, current: id,
current: id, }
} })
}) return redirect("/auth")
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 && res.status !== 200 &&
// ie. openai 404 error: Item with id 'msg_0ead8b004a3b165d0069436a6b6834819896da85b63b196a3f' not found. // ie. openai 404 error: Item with id 'msg_0ead8b004a3b165d0069436a6b6834819896da85b63b196a3f' not found.
res.status !== 404 && res.status !== 404 &&
// ie. cannot change codex model providers mid-session
!modelInfo.stickyProvider &&
modelInfo.fallbackProvider && modelInfo.fallbackProvider &&
providerInfo.id !== 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, "when": 1764110043942,
"tag": "0038_famous_magik", "tag": "0038_famous_magik",
"breakpoints": true "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", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.0.199", "version": "1.0.209",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
+1
View File
@@ -5,6 +5,7 @@ export namespace Identifier {
const prefixes = { const prefixes = {
account: "acc", account: "acc",
auth: "aut", auth: "aut",
benchmark: "ben",
billing: "bil", billing: "bil",
key: "key", key: "key",
model: "mod", 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", "name": "@opencode-ai/console-function",
"version": "1.0.199", "version": "1.0.209",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+5 -1
View File
@@ -123,7 +123,11 @@ export default {
}, },
}).then((x) => x.json())) as any }).then((x) => x.json())) as any
subject = user.id.toString() 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") { } else if (response.provider === "google") {
if (!response.id.email_verified) throw new Error("Google email not verified") if (!response.id.email_verified) throw new Error("Google email not verified")
subject = response.id.sub as string subject = response.id.sub as string
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.0.199", "version": "1.0.209",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+31 -6
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" style="background-color: var(--background-base)">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <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 name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta property="og:image" content="/social-share.png" /> <meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" /> <meta property="twitter:image" content="/social-share.png" />
</head> <!-- Theme preload script - applies cached theme to avoid FOUC -->
<body class="antialiased overscroll-none text-12-regular overflow-hidden"> <script id="oc-theme-preload-script">
<script>
;(function () { ;(function () {
const savedTheme = localStorage.getItem("theme") || "oc-1" var themeId = localStorage.getItem("opencode-theme-id")
document.documentElement.setAttribute("data-theme", savedTheme) 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> </script>
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-screen"></div> <div id="root" class="flex flex-col h-screen"></div>
<script src="/src/index.tsx" type="module"></script> <script src="/src/index.tsx" type="module"></script>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.0.199", "version": "1.0.209",
"type": "module", "type": "module",
"scripts": { "scripts": {
"typecheck": "tsgo -b", "typecheck": "tsgo -b",
+7
View File
@@ -10,6 +10,13 @@ export default defineConfig({
// //
// 1. prevent Vite from obscuring rust errors // 1. prevent Vite from obscuring rust errors
clearScreen: false, 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 // 2. tauri expects a fixed port, fail if that port is not available
server: { server: {
port: 1420, port: 1420,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.0.199", "version": "1.0.209",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.0.199" version = "1.0.209"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/sst/opencode" repository = "https://github.com/sst/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.199/opencode-darwin-arm64.zip" archive = "https://github.com/sst/opencode/releases/download/v1.0.209/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.199/opencode-darwin-x64.zip" archive = "https://github.com/sst/opencode/releases/download/v1.0.209/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.199/opencode-linux-arm64.tar.gz" archive = "https://github.com/sst/opencode/releases/download/v1.0.209/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.199/opencode-linux-x64.tar.gz" archive = "https://github.com/sst/opencode/releases/download/v1.0.209/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.199/opencode-windows-x64.zip" archive = "https://github.com/sst/opencode/releases/download/v1.0.209/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.0.199", "version": "1.0.209",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+2
View File
@@ -2,3 +2,5 @@ preload = ["@opentui/solid/preload"]
[test] [test]
preload = ["./test/preload.ts"] preload = ["./test/preload.ts"]
# Enable code coverage
coverage = true
+11 -3
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.0.199", "version": "1.0.209",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"private": true, "private": true,
@@ -50,16 +50,23 @@
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.5.1", "@agentclientprotocol/sdk": "0.5.1",
"@ai-sdk/amazon-bedrock": "3.0.57", "@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/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": "2.0.44",
"@ai-sdk/google-vertex": "3.0.81", "@ai-sdk/google-vertex": "3.0.81",
"@ai-sdk/groq": "2.0.33",
"@ai-sdk/mcp": "0.0.8", "@ai-sdk/mcp": "0.0.8",
"@ai-sdk/mistral": "2.0.26", "@ai-sdk/mistral": "2.0.26",
"@ai-sdk/openai": "2.0.71", "@ai-sdk/openai": "2.0.71",
"@ai-sdk/openai-compatible": "1.0.27", "@ai-sdk/openai-compatible": "1.0.27",
"@ai-sdk/perplexity": "2.0.22",
"@ai-sdk/provider": "2.0.0", "@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", "@ai-sdk/xai": "2.0.42",
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
@@ -81,6 +88,7 @@
"@standard-schema/spec": "1.0.0", "@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62", "@zip.js/zip.js": "2.7.62",
"ai": "catalog:", "ai": "catalog:",
"bonjour-service": "1.3.0",
"bun-pty": "0.4.2", "bun-pty": "0.4.2",
"chokidar": "4.0.3", "chokidar": "4.0.3",
"clipboardy": "4.0.0", "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 - Do not explain what the user asked for
- Write in first person (I added..., I fixed...) - Write in first person (I added..., I fixed...)
- Never ask questions or add new questions - 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 - 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 - DO NOT SAY YOU CANNOT GENERATE A TITLE OR COMPLAIN ABOUT THE INPUT
- Always output something meaningful, even if the input is minimal. - 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.) → create a title that reflects the user's tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.)
</rules> </rules>
+8 -20
View File
@@ -5,6 +5,7 @@ import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
import { ACP } from "@/acp/agent" import { ACP } from "@/acp/agent"
import { Server } from "@/server/server" import { Server } from "@/server/server"
import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
const log = Log.create({ service: "acp-command" }) const log = Log.create({ service: "acp-command" })
@@ -19,29 +20,16 @@ export const AcpCommand = cmd({
command: "acp", command: "acp",
describe: "start ACP (Agent Client Protocol) server", describe: "start ACP (Agent Client Protocol) server",
builder: (yargs) => { builder: (yargs) => {
return yargs return withNetworkOptions(yargs).option("cwd", {
.option("cwd", { describe: "working directory",
describe: "working directory", type: "string",
type: "string", default: process.cwd(),
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",
})
}, },
handler: async (args) => { handler: async (args) => {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const server = Server.listen({ const opts = await resolveNetworkOptions(args)
port: args.port, const server = Server.listen(opts)
hostname: args.hostname,
})
const sdk = createOpencodeClient({ const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`, 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 { Context } from "@actions/github/lib/context"
import type { import type {
IssueCommentEvent, IssueCommentEvent,
IssuesEvent,
PullRequestReviewCommentEvent, PullRequestReviewCommentEvent,
WorkflowDispatchEvent,
WorkflowRunEvent, WorkflowRunEvent,
PullRequestEvent, PullRequestEvent,
} from "@octokit/webhooks-types" } from "@octokit/webhooks-types"
@@ -132,7 +134,16 @@ type IssueQueryResponse = {
const AGENT_USERNAME = "opencode-agent[bot]" const AGENT_USERNAME = "opencode-agent[bot]"
const AGENT_REACTION = "eyes" const AGENT_REACTION = "eyes"
const WORKFLOW_FILE = ".github/workflows/opencode.yml" 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: // Parses GitHub remote URLs in various formats:
// - https://github.com/owner/repo.git // - 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] } 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({ export const GithubCommand = cmd({
command: "github", command: "github",
describe: "manage GitHub agent", describe: "manage GitHub agent",
@@ -397,27 +431,38 @@ export const GithubRunCommand = cmd({
core.setFailed(`Unsupported event type: ${context.eventName}`) core.setFailed(`Unsupported event type: ${context.eventName}`)
process.exit(1) 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 isCommentEvent = ["issue_comment", "pull_request_review_comment"].includes(context.eventName)
const isIssuesEvent = context.eventName === "issues"
const isScheduleEvent = context.eventName === "schedule" const isScheduleEvent = context.eventName === "schedule"
const isWorkflowDispatchEvent = context.eventName === "workflow_dispatch"
const { providerID, modelID } = normalizeModel() const { providerID, modelID } = normalizeModel()
const runId = normalizeRunId() const runId = normalizeRunId()
const share = normalizeShare() const share = normalizeShare()
const oidcBaseUrl = normalizeOidcBaseUrl() const oidcBaseUrl = normalizeOidcBaseUrl()
const { owner, repo } = context.repo 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 const payload = context.payload as
| IssueCommentEvent | IssueCommentEvent
| IssuesEvent
| PullRequestReviewCommentEvent | PullRequestReviewCommentEvent
| WorkflowDispatchEvent
| WorkflowRunEvent | WorkflowRunEvent
| PullRequestEvent | PullRequestEvent
const issueEvent = isIssueCommentEvent(payload) ? payload : undefined 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 actor = isScheduleEvent ? undefined : context.actor
const issueId = isScheduleEvent const issueId = isRepoEvent
? undefined ? undefined
: context.eventName === "issue_comment" : context.eventName === "issue_comment" || context.eventName === "issues"
? (payload as IssueCommentEvent).issue.number ? (payload as IssueCommentEvent | IssuesEvent).issue.number
: (payload as PullRequestEvent | PullRequestReviewCommentEvent).pull_request.number : (payload as PullRequestEvent | PullRequestReviewCommentEvent).pull_request.number
const runUrl = `/${owner}/${repo}/actions/runs/${runId}` const runUrl = `/${owner}/${repo}/actions/runs/${runId}`
const shareBaseUrl = isMock ? "https://dev.opencode.ai" : "https://opencode.ai" const shareBaseUrl = isMock ? "https://dev.opencode.ai" : "https://opencode.ai"
@@ -462,8 +507,8 @@ export const GithubRunCommand = cmd({
if (!useGithubToken) { if (!useGithubToken) {
await configureGit(appToken) await configureGit(appToken)
} }
// Skip permission check for schedule events (no actor to check) // Skip permission check and reactions for repo events (no actor to check, no issue to react to)
if (!isScheduleEvent) { if (isUserEvent) {
await assertPermissions() await assertPermissions()
await addReaction(commentType) await addReaction(commentType)
} }
@@ -480,25 +525,30 @@ export const GithubRunCommand = cmd({
})() })()
console.log("opencode session", session.id) console.log("opencode session", session.id)
// Handle 4 cases // Handle event types:
// 1. Schedule (no issue/PR context) // REPO_EVENTS (schedule, workflow_dispatch): no issue/PR context, output to logs/PR only
// 2. Issue // USER_EVENTS on PR (pull_request, pull_request_review_comment, issue_comment on PR): work on PR branch
// 3. Local PR // USER_EVENTS on Issue (issue_comment on issue, issues): create new branch, may create PR
// 4. Fork PR if (isRepoEvent) {
if (isScheduleEvent) { // Repo event - no issue/PR context, output goes to logs
// Schedule event - no issue/PR context, output goes to logs if (isWorkflowDispatchEvent && actor) {
const branch = await checkoutNewBranch("schedule") 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 head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const response = await chat(userPrompt, promptFiles) const response = await chat(userPrompt, promptFiles)
const { dirty, uncommittedChanges } = await branchIsDirty(head) const { dirty, uncommittedChanges } = await branchIsDirty(head)
if (dirty) { if (dirty) {
const summary = await summarize(response) 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( const pr = await createPR(
repoData.data.default_branch, repoData.data.default_branch,
branch, branch,
summary, summary,
`${response}\n\nTriggered by scheduled workflow${footer({ image: true })}`, `${response}\n\nTriggered by ${triggerType}${footer({ image: true })}`,
) )
console.log(`Created PR #${pr}`) console.log(`Created PR #${pr}`)
} else { } else {
@@ -573,7 +623,7 @@ export const GithubRunCommand = cmd({
} else if (e instanceof Error) { } else if (e instanceof Error) {
msg = e.message msg = e.message
} }
if (!isScheduleEvent) { if (isUserEvent) {
await createComment(`${msg}${footer()}`) await createComment(`${msg}${footer()}`)
await removeReaction(commentType) await removeReaction(commentType)
} }
@@ -628,9 +678,15 @@ export const GithubRunCommand = cmd({
} }
function isIssueCommentEvent( function isIssueCommentEvent(
event: IssueCommentEvent | PullRequestReviewCommentEvent | WorkflowRunEvent | PullRequestEvent, event:
| IssueCommentEvent
| IssuesEvent
| PullRequestReviewCommentEvent
| WorkflowDispatchEvent
| WorkflowRunEvent
| PullRequestEvent,
): event is IssueCommentEvent { ): event is IssueCommentEvent {
return "issue" in event return "issue" in event && "comment" in event
} }
function getReviewCommentContext() { function getReviewCommentContext() {
@@ -652,10 +708,11 @@ export const GithubRunCommand = cmd({
async function getUserPrompt() { async function getUserPrompt() {
const customPrompt = process.env["PROMPT"] const customPrompt = process.env["PROMPT"]
// For schedule events, PROMPT is required since there's no comment to extract from // For repo events and issues events, PROMPT is required since there's no comment to extract from
if (isScheduleEvent) { if (isRepoEvent || isIssuesEvent) {
if (!customPrompt) { 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: [] } return { userPrompt: customPrompt, promptFiles: [] }
} }
@@ -856,10 +913,41 @@ export const GithubRunCommand = cmd({
) )
} }
const match = result.parts.findLast((p) => p.type === "text") const text = extractResponseText(result.parts)
if (!match) throw new Error("Failed to parse the text response") 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() { async function getOidcToken() {
@@ -923,7 +1011,7 @@ export const GithubRunCommand = cmd({
await $`git config --local ${config} "${gitConfig}"` 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...") console.log("Checking out new branch...")
const branch = generateBranchName(type) const branch = generateBranchName(type)
await $`git checkout -b ${branch}` await $`git checkout -b ${branch}`
@@ -952,16 +1040,16 @@ export const GithubRunCommand = cmd({
await $`git checkout -b ${localBranch} fork/${remoteBranch}` await $`git checkout -b ${localBranch} fork/${remoteBranch}`
} }
function generateBranchName(type: "issue" | "pr" | "schedule") { function generateBranchName(type: "issue" | "pr" | "schedule" | "dispatch") {
const timestamp = new Date() const timestamp = new Date()
.toISOString() .toISOString()
.replace(/[:-]/g, "") .replace(/[:-]/g, "")
.replace(/\.\d{3}Z/, "") .replace(/\.\d{3}Z/, "")
.split("T") .split("T")
.join("") .join("")
if (type === "schedule") { if (type === "schedule" || type === "dispatch") {
const hex = crypto.randomUUID().slice(0, 6) const hex = crypto.randomUUID().slice(0, 6)
return `opencode/scheduled-${hex}-${timestamp}` return `opencode/${type}-${hex}-${timestamp}`
} }
return `opencode/${type}${issueId}-${timestamp}` return `opencode/${type}${issueId}-${timestamp}`
} }
+4 -19
View File
@@ -1,29 +1,14 @@
import { Server } from "../../server/server" import { Server } from "../../server/server"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
export const ServeCommand = cmd({ export const ServeCommand = cmd({
command: "serve", command: "serve",
builder: (yargs) => builder: (yargs) => withNetworkOptions(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",
}),
describe: "starts a headless opencode server", describe: "starts a headless opencode server",
handler: async (args) => { handler: async (args) => {
const hostname = args.hostname const opts = await resolveNetworkOptions(args)
const port = args.port const server = Server.listen(opts)
const server = Server.listen({
port,
hostname,
})
console.log(`opencode server listening on http://${server.hostname}:${server.port}`) console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
await new Promise(() => {}) await new Promise(() => {})
await server.stop() await server.stop()
+14 -5
View File
@@ -82,12 +82,21 @@ async function getAllSessions(): Promise<Session.Info[]> {
return sessions 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 sessions = await getAllSessions()
const DAYS_IN_SECOND = 24 * 60 * 60 * 1000 const MS_IN_DAY = 24 * 60 * 60 * 1000
const cutoffTime = days ? Date.now() - days * DAYS_IN_SECOND : 0
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 !== undefined) {
if (projectFilter === "") { 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 = { stats.dateRange = {
earliest: earliestTime, earliest: earliestTime,
latest: latestTime, latest: latestTime,
+1 -1
View File
@@ -539,7 +539,7 @@ function App() {
sdk.event.on(SessionApi.Event.Error.type, (evt) => { sdk.event.on(SessionApi.Event.Error.type, (evt) => {
const error = evt.properties.error const error = evt.properties.error
const message = (() => { const message = (() => {
if (!error) return "An error occured" if (!error) return "An error occurred"
if (typeof error === "object") { if (typeof error === "object") {
const data = error.data const data = error.data
@@ -37,11 +37,9 @@ export function DialogModel(props: { providerID?: string }) {
const recents = local.model.recent() const recents = local.model.recent()
const recentList = showExtra() const recentList = showExtra()
? recents ? recents.filter(
.filter( (item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
(item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID), )
)
.slice(0, 5)
: [] : []
const favoriteOptions = favorites.flatMap((item) => { 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 // Apply fuzzy filtering to each section separately, maintaining section order
if (q) { if (q) {
const filteredFavorites = fuzzysort.go(q, favoriteOptions, { keys: ["title"] }).map((x) => x.obj) 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 filteredProviders = fuzzysort.go(q, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj)
const filteredPopular = fuzzysort.go(q, popularProviders, { keys: ["title"] }).map((x) => x.obj) const filteredPopular = fuzzysort.go(q, popularProviders, { keys: ["title"] }).map((x) => x.obj)
return [...filteredFavorites, ...filteredRecents, ...filteredProviders, ...filteredPopular] return [...filteredFavorites, ...filteredRecents, ...filteredProviders, ...filteredPopular]
@@ -5,6 +5,7 @@ import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog" import { useDialog } from "@tui/ui/dialog"
import { useSDK } from "../context/sdk" import { useSDK } from "../context/sdk"
import { DialogPrompt } from "../ui/dialog-prompt" import { DialogPrompt } from "../ui/dialog-prompt"
import { Link } from "../ui/link"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2" import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2"
@@ -128,7 +129,7 @@ function AutoMethod(props: AutoMethodProps) {
<text fg={theme.textMuted}>esc</text> <text fg={theme.textMuted}>esc</text>
</box> </box>
<box gap={1}> <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> <text fg={theme.textMuted}>{props.authorization.instructions}</text>
</box> </box>
<text fg={theme.textMuted}>Waiting for authorization...</text> <text fg={theme.textMuted}>Waiting for authorization...</text>
@@ -170,7 +171,7 @@ function CodeMethod(props: CodeMethodProps) {
description={() => ( description={() => (
<box gap={1}> <box gap={1}>
<text fg={theme.textMuted}>{props.authorization.instructions}</text> <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()}> <Show when={error()}>
<text fg={theme.error}>Invalid code</text> <text fg={theme.error}>Invalid code</text>
</Show> </Show>
@@ -2,12 +2,13 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select" import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route" import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync" 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 { Locale } from "@/util/locale"
import { Keybind } from "@/util/keybind" import { Keybind } from "@/util/keybind"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk" import { useSDK } from "../context/sdk"
import { DialogSessionRename } from "./dialog-session-rename" import { DialogSessionRename } from "./dialog-session-rename"
import { useKV } from "../context/kv"
import "opentui-spinner/solid" import "opentui-spinner/solid"
export function DialogSessionList() { export function DialogSessionList() {
@@ -16,6 +17,7 @@ export function DialogSessionList() {
const { theme } = useTheme() const { theme } = useTheme()
const route = useRoute() const route = useRoute()
const sdk = useSDK() const sdk = useSDK()
const kv = useKV()
const [toDelete, setToDelete] = createSignal<string>() const [toDelete, setToDelete] = createSignal<string>()
@@ -45,7 +47,11 @@ export function DialogSessionList() {
value: x.id, value: x.id,
category, category,
footer: Locale.time(x.time.updated), 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) .slice(0, 150)
@@ -12,6 +12,38 @@ import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import type { PromptInfo } from "./history" 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 = { export type AutocompleteRef = {
onInput: (value: string) => void onInput: (value: string) => void
onKeyDown: (e: KeyEvent) => void onKeyDown: (e: KeyEvent) => void
@@ -142,9 +174,11 @@ export function Autocomplete(props: {
async (query) => { async (query) => {
if (!store.visible || store.visible === "/") return [] if (!store.visible || store.visible === "/") return []
const { lineRange, baseQuery } = extractLineRange(query ?? "")
// Get files from SDK // Get files from SDK
const result = await sdk.client.find.files({ const result = await sdk.client.find.files({
query: query ?? "", query: baseQuery,
}) })
const options: AutocompleteOption[] = [] const options: AutocompleteOption[] = []
@@ -153,15 +187,27 @@ export function Autocomplete(props: {
if (!result.error && result.data) { if (!result.error && result.data) {
const width = props.anchor().width - 4 const width = props.anchor().width - 4
options.push( options.push(
...result.data.map( ...result.data.map((item): AutocompleteOption => {
(item): AutocompleteOption => ({ let url = `file://${process.cwd()}/${item}`
display: Locale.truncateMiddle(item, width), 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: () => { onSelect: () => {
insertPart(item, { insertPart(filename, {
type: "file", type: "file",
mime: "text/plain", mime: "text/plain",
filename: item, filename,
url: `file://${process.cwd()}/${item}`, url,
source: { source: {
type: "file", type: "file",
text: { text: {
@@ -173,8 +219,8 @@ export function Autocomplete(props: {
}, },
}) })
}, },
}), }
), }),
) )
} }
@@ -383,8 +429,8 @@ export function Autocomplete(props: {
return prev return prev
} }
const result = fuzzysort.go(currentFilter, mixed, { const result = fuzzysort.go(removeLineRange(currentFilter), mixed, {
keys: [(obj) => obj.display.trimEnd(), "description", (obj) => obj.aliases?.join(" ") ?? ""], keys: [(obj) => removeLineRange(obj.display.trimEnd()), "description", (obj) => obj.aliases?.join(" ") ?? ""],
limit: 10, limit: 10,
scoreFn: (objResults) => { scoreFn: (objResults) => {
const displayResult = objResults[0] const displayResult = objResults[0]
@@ -29,6 +29,7 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderConnect } from "../dialog-provider" import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
import { DialogAlert } from "../../ui/dialog-alert" import { DialogAlert } from "../../ui/dialog-alert"
import { useToast } from "../../ui/toast" import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
export type PromptProps = { export type PromptProps = {
sessionID?: string sessionID?: string
@@ -124,6 +125,7 @@ export function Prompt(props: PromptProps) {
const command = useCommandDialog() const command = useCommandDialog()
const renderer = useRenderer() const renderer = useRenderer()
const { theme, syntax } = useTheme() const { theme, syntax } = useTheme()
const kv = useKV()
function promptModelWarning() { function promptModelWarning() {
toast.show({ toast.show({
@@ -996,8 +998,11 @@ export function Prompt(props: PromptProps) {
justifyContent={status().type === "retry" ? "space-between" : "flex-start"} justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
> >
<box flexShrink={0} flexDirection="row" gap={1}> <box flexShrink={0} flexDirection="row" gap={1}>
{/* @ts-ignore // SpinnerOptions doesn't support marginLeft */} <box marginLeft={1}>
<spinner marginLeft={1} color={spinnerDef().color} frames={spinnerDef().frames} interval={40} /> <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}> <box flexDirection="row" gap={1} flexShrink={0}>
{(() => { {(() => {
const retry = createMemo(() => { const retry = createMemo(() => {
@@ -52,7 +52,6 @@ import { DialogMessage } from "./dialog-message"
import type { PromptInfo } from "../../component/prompt/history" import type { PromptInfo } from "../../component/prompt/history"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { DialogConfirm } from "@tui/ui/dialog-confirm" import { DialogConfirm } from "@tui/ui/dialog-confirm"
import { DialogPrompt } from "@tui/ui/dialog-prompt"
import { DialogTimeline } from "./dialog-timeline" import { DialogTimeline } from "./dialog-timeline"
import { DialogForkFromTimeline } from "./dialog-fork-from-timeline" import { DialogForkFromTimeline } from "./dialog-fork-from-timeline"
import { DialogSessionRename } from "../../component/dialog-session-rename" import { DialogSessionRename } from "../../component/dialog-session-rename"
@@ -67,7 +66,7 @@ import stripAnsi from "strip-ansi"
import { Footer } from "./footer.tsx" import { Footer } from "./footer.tsx"
import { usePromptRef } from "../../context/prompt" import { usePromptRef } from "../../context/prompt"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { DialogSubagent } from "./dialog-subagent.tsx" import { DialogExportOptions } from "../../ui/dialog-export-options"
addDefaultParsers(parsers.parsers) addDefaultParsers(parsers.parsers)
@@ -128,6 +127,7 @@ export function Session() {
const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false)) const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false))
const [userMessageMarkdown, setUserMessageMarkdown] = createSignal(kv.get("user_message_markdown", true)) const [userMessageMarkdown, setUserMessageMarkdown] = createSignal(kv.get("user_message_markdown", true))
const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word") const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
const [animationsEnabled, setAnimationsEnabled] = createSignal(kv.get("animations_enabled", true))
const wide = createMemo(() => dimensions().width > 120) const wide = createMemo(() => dimensions().width > 120)
const sidebarVisible = createMemo(() => { const sidebarVisible = createMemo(() => {
@@ -584,6 +584,19 @@ export function Session() {
dialog.clear() 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", title: "Page up",
value: "session.page.up", value: "session.page.up",
@@ -770,8 +783,22 @@ export function Session() {
for (const part of parts) { for (const part of parts) {
if (part.type === "text" && !part.synthetic) { if (part.type === "text" && !part.synthetic) {
transcript += `${part.text}\n\n` 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") { } 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 sessionData = session()
const sessionMessages = messages() 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` let transcript = `# ${sessionData.title}\n\n`
transcript += `**Session ID:** ${sessionData.id}\n` transcript += `**Session ID:** ${sessionData.id}\n`
transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n` transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n`
@@ -812,22 +847,28 @@ export function Session() {
for (const part of parts) { for (const part of parts) {
if (part.type === "text" && !part.synthetic) { if (part.type === "text" && !part.synthetic) {
transcript += `${part.text}\n\n` 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") { } 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` 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 // Save to file in current working directory
const exportDir = process.cwd() const exportDir = process.cwd()
const filename = customFilename.trim() const filename = customFilename.trim()
+7 -19
View File
@@ -3,31 +3,19 @@ import { Instance } from "@/project/instance"
import path from "path" import path from "path"
import { Server } from "@/server/server" import { Server } from "@/server/server"
import { upgrade } from "@/cli/upgrade" import { upgrade } from "@/cli/upgrade"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
export const TuiSpawnCommand = cmd({ export const TuiSpawnCommand = cmd({
command: "spawn [project]", command: "spawn [project]",
builder: (yargs) => builder: (yargs) =>
yargs withNetworkOptions(yargs).positional("project", {
.positional("project", { type: "string",
type: "string", describe: "path to start opencode in",
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",
}),
handler: async (args) => { handler: async (args) => {
upgrade() upgrade()
const server = Server.listen({ const opts = await resolveNetworkOptions(args)
port: args.port, const server = Server.listen(opts)
hostname: "127.0.0.1",
})
const bin = process.execPath const bin = process.execPath
const cmd = [] const cmd = []
let cwd = process.cwd() let cwd = process.cwd()
+4 -16
View File
@@ -6,6 +6,7 @@ import path from "path"
import { UI } from "@/cli/ui" import { UI } from "@/cli/ui"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
declare global { declare global {
const OPENCODE_WORKER_PATH: string const OPENCODE_WORKER_PATH: string
@@ -15,7 +16,7 @@ export const TuiThreadCommand = cmd({
command: "$0 [project]", command: "$0 [project]",
describe: "start opencode tui", describe: "start opencode tui",
builder: (yargs) => builder: (yargs) =>
yargs withNetworkOptions(yargs)
.positional("project", { .positional("project", {
type: "string", type: "string",
describe: "path to start opencode in", describe: "path to start opencode in",
@@ -36,23 +37,12 @@ export const TuiThreadCommand = cmd({
describe: "session id to continue", describe: "session id to continue",
}) })
.option("prompt", { .option("prompt", {
alias: ["p"],
type: "string", type: "string",
describe: "prompt to use", describe: "prompt to use",
}) })
.option("agent", { .option("agent", {
type: "string", type: "string",
describe: "agent to use", 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) => { handler: async (args) => {
// Resolve relative paths against PWD to preserve behavior when using --cwd flag // Resolve relative paths against PWD to preserve behavior when using --cwd flag
@@ -87,10 +77,8 @@ export const TuiThreadCommand = cmd({
process.on("unhandledRejection", (e) => { process.on("unhandledRejection", (e) => {
Log.Default.error(e) Log.Default.error(e)
}) })
const server = await client.call("server", { const opts = await resolveNetworkOptions(args)
port: args.port, const server = await client.call("server", opts)
hostname: args.hostname,
})
const prompt = await iife(async () => { const prompt = await iife(async () => {
const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined
if (!args.prompt) return piped 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> let server: Bun.Server<BunWebSocketData>
export const rpc = { 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) if (server) await server.stop(true)
try { try {
server = Server.listen(input) server = Server.listen(input)
+9 -20
View File
@@ -1,6 +1,7 @@
import { Server } from "../../server/server" import { Server } from "../../server/server"
import { UI } from "../ui" import { UI } from "../ui"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import open from "open" import open from "open"
import { networkInterfaces } from "os" import { networkInterfaces } from "os"
@@ -28,32 +29,16 @@ function getNetworkIPs() {
export const WebCommand = cmd({ export const WebCommand = cmd({
command: "web", command: "web",
builder: (yargs) => builder: (yargs) => withNetworkOptions(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",
}),
describe: "starts a headless opencode server", describe: "starts a headless opencode server",
handler: async (args) => { handler: async (args) => {
const hostname = args.hostname const opts = await resolveNetworkOptions(args)
const port = args.port const server = Server.listen(opts)
const server = Server.listen({
port,
hostname,
})
UI.empty() UI.empty()
UI.println(UI.logo(" ")) UI.println(UI.logo(" "))
UI.empty() UI.empty()
if (hostname === "0.0.0.0") { if (opts.hostname === "0.0.0.0") {
// Show localhost for local access // Show localhost for local access
const localhostUrl = `http://localhost:${server.port}` const localhostUrl = `http://localhost:${server.port}`
UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, localhostUrl) 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 localhost in browser
open(localhostUrl.toString()).catch(() => {}) open(localhostUrl.toString()).catch(() => {})
} else { } 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"]) { for (const file of ["opencode.jsonc", "opencode.json"]) {
log.debug(`loading config from ${path.join(dir, file)}`) log.debug(`loading config from ${path.join(dir, file)}`)
result = mergeConfigWithPlugins(result, await loadFile(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.agent ??= {}
result.mode ??= {} result.mode ??= {}
result.plugin ??= [] result.plugin ??= []
@@ -141,13 +141,21 @@ export namespace Config {
if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({}) 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 { return {
config: result, config: result,
directories, 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) { async function assertValid(dir: string) {
const invalid = await Array.fromAsync( const invalid = await Array.fromAsync(
INVALID_DIRS.scan({ INVALID_DIRS.scan({
@@ -183,6 +191,10 @@ export namespace Config {
cwd: dir, cwd: dir,
}, },
).catch(() => {}) ).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") 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"), .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({ export const Layout = z.enum(["auto", "stretch"]).meta({
ref: "LayoutConfig", ref: "LayoutConfig",
}) })
@@ -633,7 +656,9 @@ export namespace Config {
$schema: z.string().optional().describe("JSON schema reference for configuration validation"), $schema: z.string().optional().describe("JSON schema reference for configuration validation"),
theme: z.string().optional().describe("Theme name to use for the interface"), theme: z.string().optional().describe("Theme name to use for the interface"),
keybinds: Keybinds.optional().describe("Custom keybind configurations"), keybinds: Keybinds.optional().describe("Custom keybind configurations"),
logLevel: Log.Level.optional().describe("Log level"),
tui: TUI.optional().describe("TUI specific settings"), tui: TUI.optional().describe("TUI specific settings"),
server: Server.optional().describe("Server configuration for opencode serve and web commands"),
command: z command: z
.record(z.string(), Command) .record(z.string(), Command)
.optional() .optional()
@@ -778,6 +803,12 @@ export namespace Config {
url: z.string().optional().describe("Enterprise URL"), url: z.string().optional().describe("Enterprise URL"),
}) })
.optional(), .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 experimental: z
.object({ .object({
hook: z hook: z
+15
View File
@@ -7,6 +7,7 @@ import path from "path"
import fs from "fs" import fs from "fs"
import ignore from "ignore" import ignore from "ignore"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Ripgrep } from "./ripgrep" import { Ripgrep } from "./ripgrep"
import fuzzysort from "fuzzysort" import fuzzysort from "fuzzysort"
@@ -235,6 +236,13 @@ export namespace File {
using _ = log.time("read", { file }) using _ = log.time("read", { file })
const project = Instance.project const project = Instance.project
const full = path.join(Instance.directory, file) 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) const bunFile = Bun.file(full)
if (!(await bunFile.exists())) { if (!(await bunFile.exists())) {
@@ -288,6 +296,13 @@ export namespace File {
ignored = ig.ignores.bind(ig) ignored = ig.ignores.bind(ig)
} }
const resolved = dir ? path.join(Instance.directory, dir) : Instance.directory 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[] = [] const nodes: Node[] = []
for (const entry of await fs.promises for (const entry of await fs.promises
.readdir(resolved, { .readdir(resolved, {
+26 -12
View File
@@ -9,9 +9,13 @@ import path from "path"
// @ts-ignore // @ts-ignore
import { createWrapper } from "@parcel/watcher/wrapper" import { createWrapper } from "@parcel/watcher/wrapper"
import { lazy } from "@/util/lazy" import { lazy } from "@/util/lazy"
import { withTimeout } from "@/util/timeout"
import type ParcelWatcher from "@parcel/watcher" import type ParcelWatcher from "@parcel/watcher"
import { $ } from "bun" import { $ } from "bun"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { readdir } from "fs/promises"
const SUBSCRIBE_TIMEOUT_MS = 10_000
declare const OPENCODE_LIBC: string | undefined declare const OPENCODE_LIBC: string | undefined
@@ -63,12 +67,16 @@ export namespace FileWatcher {
const cfgIgnores = cfg.watcher?.ignore ?? [] const cfgIgnores = cfg.watcher?.ignore ?? []
if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) { if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
subs.push( const pending = watcher().subscribe(Instance.directory, subscribe, {
await watcher().subscribe(Instance.directory, subscribe, { ignore: [...FileIgnore.PATTERNS, ...cfgIgnores],
ignore: [...FileIgnore.PATTERNS, ...cfgIgnores], backend,
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` const vcsDir = await $`git rev-parse --git-dir`
@@ -78,12 +86,18 @@ export namespace FileWatcher {
.text() .text()
.then((x) => path.resolve(Instance.worktree, x.trim())) .then((x) => path.resolve(Instance.worktree, x.trim()))
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
subs.push( const gitDirContents = await readdir(vcsDir).catch(() => [])
await watcher().subscribe(vcsDir, subscribe, { const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD")
ignore: ["hooks", "info", "logs", "objects", "refs", "worktrees", "modules", "lfs"], const pending = watcher().subscribe(vcsDir, subscribe, {
backend, 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 } return { subs }
+18
View File
@@ -313,3 +313,21 @@ export const gleam: Info = {
return Bun.which("gleam") !== null 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()) .usage("\n" + UI.logo())
.completion("completion", "generate shell completion script")
.command(AcpCommand) .command(AcpCommand)
.command(McpCommand) .command(McpCommand)
.command(TuiThreadCommand) .command(TuiThreadCommand)
+18 -10
View File
@@ -166,6 +166,7 @@ export namespace Installation {
export async function latest(installMethod?: Method) { export async function latest(installMethod?: Method) {
const detectedMethod = installMethod || (await method()) const detectedMethod = installMethod || (await method())
if (detectedMethod === "brew") { if (detectedMethod === "brew") {
const formula = await getBrewFormula() const formula = await getBrewFormula()
if (formula === "opencode") { if (formula === "opencode") {
@@ -178,19 +179,26 @@ export namespace Installation {
} }
} }
const registry = await iife(async () => { if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") {
const r = (await $`npm config get registry`.quiet().nothrow().text()).trim() const registry = await iife(async () => {
const reg = r || "https://registry.npmjs.org" const r = (await $`npm config get registry`.quiet().nothrow().text()).trim()
return reg.endsWith("/") ? reg.slice(0, -1) : reg 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
const channel = CHANNEL return fetch(`${registry}/opencode-ai/${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) => { .then((res) => {
if (!res.ok) throw new Error(res.statusText) if (!res.ok) throw new Error(res.statusText)
return res.json() 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", ".hbs": "handlebars",
".handlebars": "handlebars", ".handlebars": "handlebars",
".hs": "haskell", ".hs": "haskell",
".lhs": "haskell",
".html": "html", ".html": "html",
".htm": "html", ".htm": "html",
".ini": "ini", ".ini": "ini",
+23 -4
View File
@@ -196,13 +196,14 @@ export namespace LSPServer {
} }
await fs.rename(extractedPath, finalPath) await fs.rename(extractedPath, finalPath)
await $`npm install`.cwd(finalPath).quiet() const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"
await $`npm run compile`.cwd(finalPath).quiet() await $`${npmCmd} install`.cwd(finalPath).quiet()
await $`${npmCmd} run compile`.cwd(finalPath).quiet()
log.info("installed VS Code ESLint server", { serverPath }) 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, cwd: root,
env: { env: {
...process.env, ...process.env,
@@ -1175,7 +1176,7 @@ export namespace LSPServer {
case "linux": case "linux":
return "config_linux" return "config_linux"
case "win32": case "win32":
return "config_windows" return "config_win"
default: default:
return "config_linux" 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") { if (mcp.type === "local") {
const [cmd, ...args] = mcp.command const [cmd, ...args] = mcp.command
const cwd = Instance.directory
const transport = new StdioClientTransport({ const transport = new StdioClientTransport({
stderr: "ignore", stderr: "ignore",
command: cmd, command: cmd,
args, args,
cwd,
env: { env: {
...process.env, ...process.env,
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}), ...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
@@ -334,6 +336,7 @@ export namespace MCP {
log.error("local mcp startup failed", { log.error("local mcp startup failed", {
key, key,
command: mcp.command, command: mcp.command,
cwd,
error: error instanceof Error ? error.message : String(error), error: error instanceof Error ? error.message : String(error),
}) })
status = { status = {
+11
View File
@@ -86,6 +86,17 @@ export namespace Permission {
return state().pending 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: { export async function ask(input: {
type: Info["type"] type: Info["type"]
title: Info["title"] title: Info["title"]
+24 -9
View File
@@ -165,29 +165,44 @@ export namespace Provider {
} }
}, },
"amazon-bedrock": async () => { "amazon-bedrock": async () => {
const [awsProfile, awsAccessKeyId, awsBearerToken, awsRegion] = await Promise.all([ const auth = await Auth.get("amazon-bedrock")
Env.get("AWS_PROFILE"), const awsProfile = Env.get("AWS_PROFILE")
Env.get("AWS_ACCESS_KEY_ID"), const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID")
Env.get("AWS_BEARER_TOKEN_BEDROCK"), const awsRegion = Env.get("AWS_REGION")
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 } 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")) const { fromNodeProviderChain } = await import(await BunProc.install("@aws-sdk/credential-providers"))
return { return {
autoload: true, autoload: true,
options: { options: {
region, region: defaultRegion,
credentialProvider: fromNodeProviderChain(), 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 // Skip region prefixing if model already has global prefix
if (modelID.startsWith("global.")) { if (modelID.startsWith("global.")) {
return sdk.languageModel(modelID) 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] let regionPrefix = region.split("-")[0]
switch (regionPrefix) { switch (regionPrefix) {
+13 -11
View File
@@ -214,7 +214,7 @@ export namespace ProviderTransform {
const id = model.id.toLowerCase() const id = model.id.toLowerCase()
if (id.includes("qwen")) return 0.55 if (id.includes("qwen")) return 0.55
if (id.includes("claude")) return undefined 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.6")) return 1.0
if (id.includes("glm-4.7")) return 1.0 if (id.includes("glm-4.7")) return 1.0
if (id.includes("minimax-m2")) 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 if (id.includes("m2.1")) return 0.9
return 0.95 return 0.95
} }
if (id.includes("gemini")) return 0.95
return undefined return undefined
} }
export function topK(model: Provider.Model) { export function topK(model: Provider.Model) {
const id = model.id.toLowerCase() const id = model.id.toLowerCase()
if (id.includes("minimax-m2")) return 20 if (id.includes("minimax-m2")) return 20
if (id.includes("gemini")) return 64
return undefined return undefined
} }
@@ -300,22 +302,22 @@ export namespace ProviderTransform {
} }
export function smallOptions(model: Provider.Model) { export function smallOptions(model: Provider.Model) {
const options: Record<string, any> = {}
if (model.providerID === "openai" || model.api.id.includes("gpt-5")) { if (model.providerID === "openai" || model.api.id.includes("gpt-5")) {
if (model.api.id.includes("5.")) { if (model.api.id.includes("5.")) {
options["reasoningEffort"] = "low" return { reasoningEffort: "low" }
} else {
options["reasoningEffort"] = "minimal"
} }
return { reasoningEffort: "minimal" }
} }
if (model.providerID === "google") { if (model.providerID === "google") {
options["thinkingConfig"] = { return { thinkingConfig: { thinkingBudget: 0 } }
thinkingBudget: 0,
}
} }
if (model.providerID === "openrouter") {
return options if (model.api.id.includes("google")) {
return { reasoning: { enabled: false } }
}
return { reasoningEffort: "minimal" }
}
return {}
} }
export function providerOptions(model: Provider.Model, options: { [x: string]: any }) { 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 { SessionSummary } from "@/session/summary"
import { SessionStatus } from "@/session/status" import { SessionStatus } from "@/session/status"
import { upgradeWebSocket, websocket } from "hono/bun" import { upgradeWebSocket, websocket } from "hono/bun"
import type { BunWebSocketData } from "hono/bun"
import { errors } from "./error" import { errors } from "./error"
import { Pty } from "@/pty" import { Pty } from "@/pty"
import { Installation } from "@/installation" 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 // @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 globalThis.AI_SDK_LOG_WARNINGS = false
@@ -1082,6 +1084,8 @@ export namespace Server {
async (c) => { async (c) => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json") const body = c.req.valid("json")
const session = await Session.get(sessionID)
await SessionRevert.cleanup(session)
const msgs = await Session.messages({ sessionID }) const msgs = await Session.messages({ sessionID })
let currentAgent = await Agent.defaultAgent() let currentAgent = await Agent.defaultAgent()
for (let i = msgs.length - 1; i >= 0; i--) { for (let i = msgs.length - 1; i >= 0; i--) {
@@ -1528,6 +1532,28 @@ export namespace Server {
return c.json(true) 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( .get(
"/command", "/command",
describeRoute({ describeRoute({
@@ -2623,20 +2649,41 @@ export namespace Server {
return result return result
} }
export function listen(opts: { port: number; hostname: string }) { export function listen(opts: { port: number; hostname: string; mdns?: boolean }) {
const args = { const args = {
hostname: opts.hostname, hostname: opts.hostname,
idleTimeout: 0, idleTimeout: 0,
fetch: App().fetch, fetch: App().fetch,
websocket: websocket, websocket: websocket,
} as const } as const
if (opts.port === 0) { const tryServe = (port: number) => {
try { try {
return Bun.serve({ ...args, port: 4096 }) return Bun.serve({ ...args, port })
} catch { } 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 { MessageV2 } from "./message-v2"
import z from "zod" import z from "zod"
import { SessionPrompt } from "./prompt" import { SessionPrompt } from "./prompt"
import { Flag } from "../flag/flag"
import { Token } from "../util/token" import { Token } from "../util/token"
import { Log } from "../util/log" import { Log } from "../util/log"
import { SessionProcessor } from "./processor" import { SessionProcessor } from "./processor"
import { fn } from "@/util/fn" import { fn } from "@/util/fn"
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
import { Config } from "@/config/config"
export namespace SessionCompaction { export namespace SessionCompaction {
const log = Log.create({ service: "session.compaction" }) 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 }) { export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) return false const config = await Config.get()
if (config.compaction?.auto === false) return false
const context = input.model.limit.context const context = input.model.limit.context
if (context === 0) return false if (context === 0) return false
const count = input.tokens.input + input.tokens.cache.read + input.tokens.output 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 // calls. then erases output of previous tool calls. idea is to throw away old
// tool calls that are no longer relevant. // tool calls that are no longer relevant.
export async function prune(input: { sessionID: string }) { 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") log.info("pruning")
const msgs = await Session.messages({ sessionID: input.sessionID }) const msgs = await Session.messages({ sessionID: input.sessionID })
let total = 0 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) { export const stream = fn(Identifier.schema("session"), async function* (sessionID) {
+1 -1
View File
@@ -459,7 +459,7 @@ export namespace SessionPrompt {
if ( if (
lastFinished && lastFinished &&
lastFinished.summary !== true && lastFinished.summary !== true &&
SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model }) (await SessionCompaction.isOverflow({ tokens: lastFinished.tokens, model }))
) { ) {
await SessionCompaction.create({ await SessionCompaction.create({
sessionID, sessionID,

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