Compare commits

...

324 Commits

Author SHA1 Message Date
opencode ca65da2d9e release: v1.0.165 2025-12-17 21:46:21 +00:00
Adam e48d804d84 feat(desktop): startup errors shown 2025-12-17 15:42:55 -06:00
Adam b4209582fb feat(desktop): optimistic prompt submit 2025-12-17 15:42:55 -06:00
Aiden Cline dbdea2f659 fix: better error messages 2025-12-17 15:32:44 -06:00
Aiden Cline a50ab4b5b5 fix: prevent 1 from showing when preparing write 2025-12-17 15:25:04 -06:00
Nalin Singh 4d7c3f56fa feat: add viewportOptions to scrollbox for padding adjustments to avoid scrollbar overlap (#5703) 2025-12-17 15:09:41 -06:00
Spoon 16b41d2bea UI: show plugins in /status (#4515)
Co-authored-by: GitHub Action <action@github.com>
2025-12-17 15:01:52 -06:00
Nalin Singh a8c499ae8f fix: prevent session list selection from jumping to active session when confirming delete (#5666) 2025-12-17 14:35:46 -06:00
Joel Hooks 24430287c5 feat(plugin): add experimental.session.compacting hook for pre-compaction context injection (#5698)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-12-17 13:57:09 -06:00
Github Action 1f52731255 Update Nix flake.lock and hashes 2025-12-17 19:13:06 +00:00
Adam 4a3ba58f65 chore: localStorage -> tauri store 2025-12-17 13:11:02 -06:00
Brendan Allan 2a3a8a1ec2 console: use download proxy to rename mac and windows installers (#5697)
Co-authored-by: GitHub Action <action@github.com>
2025-12-18 01:59:23 +08:00
Ravi Kumar 69e562125d fix(tui): resolve session_status TypeError (#5520) 2025-12-17 11:38:05 -06:00
Aiden Cline b5e97eb338 fix: keep session dialog open if deleting session 2025-12-17 11:29:55 -06:00
Aiden Cline 16e6941495 fix: remove needless tui event publish on session delete 2025-12-17 11:29:55 -06:00
GitHub Action f033e0317e chore: format code 2025-12-17 17:02:36 +00:00
Adam ddd88f92cc fix: sticky visual issues 2025-12-17 11:02:00 -06:00
Aiden Cline 99101edc13 ci: add windows label to triage bot 2025-12-17 11:01:53 -06:00
GitHub Action 6e85a07977 chore: format code 2025-12-17 16:59:57 +00:00
Brendan Allan be1a3536ae console: add /download/[platform] endpoint 2025-12-18 00:59:16 +08:00
Qio 1e4bfbcf6f add OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX to override 32k default (#5679)
Co-authored-by: qio <handsomehust@gmail.com>
2025-12-17 10:35:43 -06:00
Adam 204e3bf382 feat(desktop): inter and ibm plex mono 2025-12-17 10:30:35 -06:00
Adam 8fb014a48d feat(desktop): inter and ibm plex mono 2025-12-17 10:30:03 -06:00
Paolo Ricciuti 57c3cf1f8b fix: send mcpName as state if authUrl doesn't have state (#5681) 2025-12-17 10:26:23 -06:00
Aiden Cline f9d0850c5e test: add regression test for setCacheKey option 2025-12-17 10:24:53 -06:00
Spoon 8864da7a77 batch: enable edit, todoread, clarify error message, minor tool description change (#5659) 2025-12-17 10:23:35 -06:00
Rhys Sullivan 1b39199083 fix: change subagent navigation order to newest-to-oldest (#5680) 2025-12-17 10:22:57 -06:00
Shantur Rathore b8204c0bb7 fix: config option setCacheKey not being respected (#5686) 2025-12-17 10:20:10 -06:00
Aiden Cline fe8c5c143e docs: update share link 2025-12-17 10:18:30 -06:00
Frank d6f86e9bb7 zen: add gemini 3 flash 2025-12-17 11:10:58 -05:00
Brendan Allan bf00b2bfc9 tauri: nsis header and sidebar 2025-12-18 00:02:16 +08:00
Brendan Allan 382ec8fb2c tauri: update nsis icon 2025-12-17 23:40:52 +08:00
Stoufiler 6454adcd69 docs: Sort LSP Server list (#5688) 2025-12-17 09:14:26 -06:00
Rohan Mukherjee 99548554d7 feat: added lucent-orng theme (#5678)
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-17 09:10:35 -06:00
Sachnun 751899eeec fix: remove unsupported parameter from bash tool description (#5676) 2025-12-17 08:59:42 -06:00
Brendan Allan f8df1d3185 tauri: return after update failures 2025-12-17 22:54:54 +08:00
GitHub Action b07a47fc89 chore: format code 2025-12-17 14:52:05 +00:00
Brendan Allan c6f84f32d7 tauri: only alert on update failure when triggered manually 2025-12-17 22:51:14 +08:00
Brendan Allan ebe25c3e9a tauri: dev icons + separate prod config (#5691)
Co-authored-by: GitHub Action <action@github.com>
2025-12-17 22:23:03 +08:00
Adam 65d7fc3ccd fix: command shortcuts 2025-12-17 07:36:53 -06:00
Github Action 4f3037d803 Update Nix flake.lock and hashes 2025-12-17 13:35:05 +00:00
Amadeus Demarzi 5c490c51ed Diffs Performance Improvements (#5653)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2025-12-17 07:33:46 -06:00
GitHub Action 5da1c0087b ignore: update download stats 2025-12-17 2025-12-17 12:04:41 +00:00
David Hill 4375149e63 wip: auto-detect OS and show desktop download button 2025-12-17 11:43:04 +00:00
David Hill b695d3b6bb fix: website cta button 2025-12-17 11:21:57 +00:00
Adam d7e133732c chore: cleanup 2025-12-17 03:58:16 -06:00
Adam 494e6fff01 feat(desktop): share sessions 2025-12-17 03:47:49 -06:00
Adam 0c7a297b1d feat(desktop): lsp diagnostics displayed 2025-12-17 03:47:48 -06:00
Github Action 9b1f9007c3 Update Nix flake.lock and hashes 2025-12-17 08:41:07 +00:00
GitHub Action 34ef5f4ece chore: format code 2025-12-17 08:40:22 +00:00
Sebastian Herrlinger 73ad20b90c upgrade opentui to v0.1.61 2025-12-17 09:38:04 +01:00
Jeon Suyeol 340e80257a Add availability to disable terminal title using OPENCODE_DISABLE_TERMINAL_TITLE env (#5661) 2025-12-16 23:57:03 -06:00
Dax Raad c23ea2a211 ci: update publish workflow configuration 2025-12-16 21:01:44 -05:00
GitHub Action a5f964aec6 chore: format code 2025-12-17 01:28:42 +00:00
Spoon b8a8fb0de6 plugin(hook): add task tool execution hooks and command context tracking (#5642) 2025-12-16 19:28:09 -06:00
Aiden Cline a6a8f41fd3 ci: tweak triage 2025-12-16 19:27:44 -06:00
Matt Silverlock c137babea3 github: add configurable mentions input (#5655) 2025-12-16 19:14:50 -06:00
Adam db2abc1b2c tui: increase session width to accommodate longer code blocks and improve readability 2025-12-16 17:31:26 -06:00
David Hill a0f9f8dabb fix: load more button 2025-12-16 23:23:18 +00:00
Aiden Cline 8a185aa678 ci: fix missing pkg issue 2025-12-16 16:31:46 -06:00
Dax Raad 29aaf4f000 ci: fix release draft configuration to prevent automatic draft flag 2025-12-16 17:17:06 -05:00
GitHub Action fc940dfcfb chore: format code 2025-12-16 22:07:10 +00:00
Adam 2f2ea98937 fix(share): content wasn't centered 2025-12-16 16:06:28 -06:00
opencode ef0fa2007b release: v1.0.164 2025-12-16 21:47:41 +00:00
Adam f07d4b933c fix(desktop): prompt history nav 2025-12-16 15:42:35 -06:00
Aiden Cline 5f57cee8e4 fix: user invoked subtasks causing tool_use or missing thinking signa… (#5650) 2025-12-16 15:42:21 -06:00
Adam 1755a3fe07 fix(desktop): auto-scroll 2025-12-16 15:32:14 -06:00
Adam 99680baf83 fix(desktop): focus prompt input after dialog close 2025-12-16 15:25:00 -06:00
Adam 9aa5460a0e fix(desktop): prompt history navigation 2025-12-16 15:10:44 -06:00
Adam b4014e5baa fix: auto-scroll 2025-12-16 15:10:43 -06:00
Adam 96e4dcb521 fix: working logic 2025-12-16 15:10:43 -06:00
Adam 7e682a95c4 fix: prompt input multi line input 2025-12-16 15:10:43 -06:00
Adam 5eeba76bc5 fix: defensive audio init 2025-12-16 15:10:43 -06:00
Eric Guo a2c91ebc32 feat(desktop): Loading more session number per project by button (#5616)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-12-16 14:50:33 -06:00
matvey 1aee8b49e1 feat: add experimental oxfmt formatter (#5620) 2025-12-16 14:43:14 -06:00
Aiden Cline 984f17ddd7 ci: include desktop & tauri in release notes 2025-12-16 14:37:33 -06:00
Aiden Cline d556143e3b ci: fix branch name 2025-12-16 14:35:42 -06:00
Aiden Cline 7e3ad770ac fix: git branch filewatcher, add flag to completely disable watcher 2025-12-16 14:31:09 -06:00
Aiden Cline 87524de265 ci: fix triage 2025-12-16 14:15:50 -06:00
Aiden Cline ee10d9b898 ci: auto tag github action once a change is shipped for it 2025-12-16 14:13:51 -06:00
Dax Raad bbd36e8441 core: update plugin dependency and config loading for .opencode directory support 2025-12-16 15:06:50 -05:00
Dax Raad 4e2d1acf7d core: fix Tauri desktop app SSE connection timeout
- Add heartbeat events to /global/event and /event SSE endpoints
- Send server.heartbeat event every 30s to prevent WKWebView 60s timeout
- Fixes desktop app disconnecting from global events after 1 minute
2025-12-16 14:45:03 -05:00
Dax Raad 40d63cd1e3 fix 2025-12-16 14:25:12 -05:00
Dax Raad 77b2331428 ignore: update opencode plugin dependency 2025-12-16 14:21:19 -05:00
Dax Raad 2b7e2edee5 core: ensure desktop app loads user shell environment variables
Changes shell spawn flags from -l to -il so that ~/.zshrc and
~/.bashrc are sourced when starting the desktop app on macOS
and Linux. This fixes missing PATH and other environment
variables that users expect to be available.
2025-12-16 14:21:19 -05:00
Adam 28aba35ff9 feat(desktop): show retries 2025-12-16 13:19:32 -06:00
Adam 89219a77f7 fix: layout badness 2025-12-16 12:53:12 -06:00
Adam 20e3a74bad fix: defensive audio init 2025-12-16 12:53:12 -06:00
Adam ff690350b1 feat(desktop): show write tool output 2025-12-16 12:53:11 -06:00
Adam ebefb26e8f chore: cleanup 2025-12-16 12:53:11 -06:00
Dax Raad 0b1ee9ddd9 Merge remote-tracking branch 'origin/dev' into dev 2025-12-16 13:52:50 -05:00
Dax Raad 79599f351e chore: update opencode plugin dependencies and fix tauri sidecar path 2025-12-16 13:52:40 -05:00
Github Action 8c9f6b1d3e Update Nix flake.lock and hashes 2025-12-16 18:38:59 +00:00
Dax Raad 83bcb9e95b tui: fix autocomplete file loading and update dependencies 2025-12-16 13:37:22 -05:00
David Hill 96b9ff8d0e fix: remove the selected state from button when select deselected 2025-12-16 17:41:56 +00:00
David Hill 0af2254856 wip: add active state to open select 2025-12-16 17:35:04 +00:00
Fran Zekan c2944024a8 fix: enable shell alias expansion in ! command (#5621) 2025-12-16 11:32:31 -06:00
GitHub Action 5be4bda90f chore: format code 2025-12-16 17:30:08 +00:00
shekohex b78e2db013 docs: fix typo in Google Antigravity github link (#5625) 2025-12-16 11:29:31 -06:00
jinzhongjia 3f4d1121a4 docs: Add new project entry for opencode.nvim frontend (#5626) 2025-12-16 11:29:06 -06:00
Connor Adams def910021d docs: add homebrew command for opencode-desktop (#5631)
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-16 11:28:40 -06:00
Aiden Cline 3ac42e9632 fix: github install cmd if repo has . in it 2025-12-16 11:19:18 -06:00
David Hill 9c26bb7c6c fix: breadcrumb dropdown position left aligned 2025-12-16 17:16:51 +00:00
David Hill 53f20f7612 Revert "wip: make the default container wider"
This reverts commit 1f18f389c0.
2025-12-16 17:16:51 +00:00
Tommy D. Rossi 11b3927dc2 fix: use system prompt field from prompt input (#5633)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-16 10:52:22 -06:00
David Hill a190eda2c8 Merge branch 'dev' of https://github.com/sst/opencode into dev 2025-12-16 16:36:15 +00:00
David Hill 1f18f389c0 wip: make the default container wider 2025-12-16 16:35:57 +00:00
GitHub Action 84e56ee614 chore: format code 2025-12-16 16:10:36 +00:00
Aiden Cline 59329a414d ci: tweak triage 2025-12-16 10:09:51 -06:00
Brendan Allan 452c991f58 Keep release a draft until all builds are finished (#5632)
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: GitHub Action <action@github.com>
2025-12-16 23:34:44 +08:00
Simon D'Morias be8116e2ea fix: preserve argument boundaries in run command (#4979) 2025-12-16 06:21:51 -06:00
David Hill f0ed1e38c9 Revert "fix: strip parentheses from file paths generated by llm"
This reverts commit 6c1a1a77b7.
2025-12-16 12:12:01 +00:00
GitHub Action ac0f1dbbdd ignore: update download stats 2025-12-16 2025-12-16 12:04:54 +00:00
GitHub Action 275a352e81 chore: format code 2025-12-16 11:51:08 +00:00
David Hill 9f3bc0e352 Merge branch 'dev' of https://github.com/sst/opencode into dev 2025-12-16 11:50:25 +00:00
David Hill 6c1a1a77b7 fix: strip parentheses from file paths generated by llm 2025-12-16 11:50:23 +00:00
David Hill 2e21c62320 fix: font size updates 2025-12-16 11:48:52 +00:00
David Hill 19c6fec4d1 wip: font-size updates 2025-12-16 11:12:08 +00:00
Brendan Allan 4779d99a13 tauri: explicitly kill sidecar before updater relaunch 2025-12-16 19:01:37 +08:00
David Hill 05e0759878 Merge branch 'dev' of https://github.com/sst/opencode into dev 2025-12-16 10:54:51 +00:00
David Hill 2330ec6dc3 fix: font size updates 2025-12-16 10:54:50 +00:00
GitHub Action 75e5130cf8 chore: format code 2025-12-16 10:33:49 +00:00
Brendan Allan 87efd27459 tauri: macos-only app menu 2025-12-16 18:33:04 +08:00
Aiden Cline 62f080b0e4 fix: small bug w/ install script 2025-12-16 00:20:05 -06:00
Aiden Cline ae3990a557 chore: centralize dep to catalog & fix typos 2025-12-15 23:07:55 -06:00
Aiden Cline d7b5b431d6 ci: Update issue assignment and labeling guidelines
Clarified assignment responsibilities and labeling for issues related to OpenCode tools.
2025-12-15 22:11:30 -06:00
Dax Raad e2fbd098d2 tui: fix dialog select items taking up 2 lines when truncated
Prevents text wrapping in dialog select options by removing wrapMode,
ensuring truncated text stays on single line and maintains proper timestamp visibility
2025-12-15 22:57:52 -05:00
Luke Parker ef78fd8bae fix: debounce LSP diagnostics to get complete results (#5600) 2025-12-15 21:26:59 -06:00
DS 72ebaeb8f7 fix: rejoin system prompt if experimental plugin hook triggers to preserve caching (#5550)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-15 20:00:26 -06:00
Lucas Duailibe 0dc62d5dad make install script use tmp dir (#5601) 2025-12-15 19:58:07 -06:00
Aiden Cline d118782a10 ci: cheaper model 2025-12-15 19:24:30 -06:00
Aiden Cline ff05647350 ci: ignore 2025-12-15 19:18:39 -06:00
GitHub Action 0e1c711c4e chore: format code 2025-12-16 01:16:20 +00:00
Aiden Cline bfb254dac6 ci: auto triage issues 2025-12-15 19:15:40 -06:00
opencode 92fe927785 release: v1.0.162 2025-12-16 00:40:27 +00:00
GitHub Action 2e25fe9d5d chore: format code 2025-12-16 00:36:01 +00:00
Dax Raad 38c5f23f4a tui: update dialog context and server to use new single dialog system 2025-12-15 19:35:19 -05:00
Dax Raad 112c58abf5 tui: refactor dialog system to use single active dialog instead of stack 2025-12-15 19:35:18 -05:00
opencode 0dce5173cc release: v1.0.161 2025-12-16 00:17:15 +00:00
Adam 2c70c0b00f fix: undefined events 2025-12-15 18:13:11 -06:00
opencode-agent[bot] 34024c2504 docs: models --refresh flag in cli.mdx (#5596)
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-15 18:04:47 -06:00
Luke Parker 27e826eba6 fix(win32): Normalise LSP paths on windows (fixes lua) (#5597) 2025-12-15 18:01:03 -06:00
Aiden Cline 89a4f1c1ae tweak: add .catch for extractZip calls 2025-12-15 17:41:28 -06:00
GitHub Action c0c61b25ff chore: format code 2025-12-15 23:30:06 +00:00
Luke Parker 0d1c6e0ca9 fix(win32): Missing LSP can now unzip on windows (#5594) 2025-12-15 17:29:30 -06:00
opencode 002db3abf4 release: v1.0.160 2025-12-15 23:26:00 +00:00
Dax Raad 416a919c6d tui: fix dialog replacement to prevent nested dialogs from showing simultaneously 2025-12-15 18:21:54 -05:00
Dax Raad dbbcf0b8d0 tui: fix model selection dialog to properly replace current dialog instead of creating nested dialogs 2025-12-15 18:14:33 -05:00
Luke Parker efac8cebb3 fix(win32): correct ElixirLS extension typo (#5590) 2025-12-15 16:47:27 -06:00
GitHub Action 4f2baf1a72 chore: format code 2025-12-15 22:40:18 +00:00
Luke Parker 48b2bde6e5 fix(win32): use path.delimiter for PATH separator in LSP server lookups (#5589) 2025-12-15 16:39:42 -06:00
opencode 88314148e6 release: v1.0.159 2025-12-15 22:19:08 +00:00
Dax Raad 56452d886d fix dialog root complexity 2025-12-15 17:13:20 -05:00
Luke Parker f3e64cfb19 fix(windows): opencode github install (#5587) 2025-12-15 16:12:54 -06:00
Ariane Emory 8fcc80bc20 fix: restore ability to bind keys for model_cycle_favorite model_cycle_favorite_reverse (resolves #5198) (#5202) 2025-12-15 16:03:50 -06:00
opencode-agent[bot] 0beccc406e docs: enabled_providers docs section (#5586)
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-15 16:01:35 -06:00
Adam b82ea693db fix: multiline user input 2025-12-15 15:22:39 -06:00
Adam 4fd9a19fbb fix: keybinds for agent and model selection 2025-12-15 15:19:19 -06:00
Adam e16487b804 fix: landing page CLS hero jump down 2025-12-15 15:10:48 -06:00
Adam 5388192aac fix: session nav on homepage 2025-12-15 15:10:17 -06:00
Adam 8010448ba1 fix: default steps expanded unless done 2025-12-15 15:03:00 -06:00
Adam 66f3e69867 fix: landing page CLS hero jump down 2025-12-15 14:59:24 -06:00
Aiden Cline ca599ab8fc tweak: add model flag support for agent create command 2025-12-15 14:55:59 -06:00
Jay V c3b3b133b0 docs: make project names clickable links in ecosystem documentation 2025-12-15 15:44:46 -05:00
Adam 300ec0e0af fix: missing event type (global) 2025-12-15 14:43:48 -06:00
Ariane Emory 6632987827 fix: record shell mode in history (resolves #5454) (#5551) 2025-12-15 14:42:17 -06:00
GitHub Action e555e893c4 chore: format code 2025-12-15 20:35:39 +00:00
Aiden Cline 81134cf61e add ability to set topK 2025-12-15 14:34:56 -06:00
opencode 37e4c1e619 release: v1.0.158 2025-12-15 20:28:44 +00:00
Adam 02b5e7d72c fix: modal search 2025-12-15 14:25:19 -06:00
justfortheloveof 7abc2a947e tweak: prioritize fuzzysort results that start with user input (#5571) 2025-12-15 14:22:37 -06:00
Adam 337a7e9646 fix: allow for non-vcs projects in desktop 2025-12-15 14:16:08 -06:00
GitHub Action 62cc532ecc chore: format code 2025-12-15 20:09:08 +00:00
Dax Raad d5a506d4ae core: fix server response handling to prevent connection timeouts 2025-12-15 15:07:20 -05:00
opencode 9c5f94bd66 release: v1.0.157 2025-12-15 19:46:23 +00:00
Dax Raad 83390314d6 ci: fix tauri updater version mismatch by checking out the release tag 2025-12-15 14:42:34 -05:00
Dax Raad 8b08e9cda5 ci: add app bundle target to fix macOS updater by generating .app.tar.gz files 2025-12-15 14:41:38 -05:00
GitHub Action b1b1df824c chore: format code 2025-12-15 19:33:56 +00:00
Dax Raad 7d1733c752 core: fix message caching for Anthropic models to improve response consistency 2025-12-15 14:33:14 -05:00
opencode cf05e6e02b release: v1.0.156 2025-12-15 19:28:02 +00:00
Adam 7e49d0fb15 fix: connect provider on homepage 2025-12-15 13:24:30 -06:00
Adam c4f63824df fix: terminal in desktop 2025-12-15 13:09:13 -06:00
Adam 4236744fb5 fix: image attachments in desktop 2025-12-15 12:01:11 -06:00
Github Action 284c045795 Update Nix flake.lock and hashes 2025-12-15 16:57:28 +00:00
Dax Raad 2c53abd70c docs: update header navigation to include desktop download 2025-12-15 11:56:53 -05:00
Adam b7a9cbfc68 fix: share page 2025-12-15 10:56:07 -06:00
Dax Raad 46a35dfc1b docs: restore desktop beta banner to homepage 2025-12-15 11:54:09 -05:00
GitHub Action b7597c12dd chore: format code 2025-12-15 16:52:45 +00:00
Nalin Singh 6830590183 feat: add F# language server support (#5549) 2025-12-15 10:52:16 -06:00
opencode b9b4349039 release: v1.0.155 2025-12-15 16:44:08 +00:00
Dax Raad 4107918909 20min 2025-12-15 11:31:38 -05:00
Adam 6347ee9988 chore: update stats 2025-12-15 10:31:11 -06:00
GitHub Action 9daa4e04ea chore: format code 2025-12-15 16:22:49 +00:00
Adam ed96ae9d45 chore: cleanup 2025-12-15 10:22:07 -06:00
Adam 8ce0966987 wip(desktop): progress 2025-12-15 10:22:06 -06:00
Adam 8cb26b6066 wip(desktop): progress 2025-12-15 10:22:06 -06:00
Adam 5cf6a1343c wip(desktop): progress 2025-12-15 10:22:04 -06:00
Adam 44d6c5780d wip(desktop): progress 2025-12-15 10:20:20 -06:00
Adam 5eaa8e1bf4 chore: cleanup 2025-12-15 10:20:20 -06:00
Adam df2713a6c2 chore: cleanup 2025-12-15 10:20:19 -06:00
Adam ff6864a7ca feat(desktop): custom commands 2025-12-15 10:20:19 -06:00
Adam 5e37a902ce wip(desktop): progress 2025-12-15 10:20:19 -06:00
Adam df2ebfac7d wip(desktop): progress 2025-12-15 10:20:19 -06:00
Adam 5fbcb203f5 wip(desktop): progress 2025-12-15 10:20:19 -06:00
Adam 34db739442 wip(desktop): progress 2025-12-15 10:20:18 -06:00
Adam ae8c4154aa wip(desktop): progress 2025-12-15 10:20:18 -06:00
Adam 315836c0b7 wip(desktop): progress 2025-12-15 10:20:18 -06:00
Adam c0d009d5f3 wip(desktop): progress 2025-12-15 10:20:18 -06:00
Adam c36f3b9dbe wip(desktop): progress 2025-12-15 10:20:17 -06:00
Adam d31824320e Revert "wip(desktop): session turn state consolidation"
This reverts commit 453f862616dc4d3ac90680581cde279e118b0da1.
2025-12-15 10:20:17 -06:00
Adam 88c0675148 wip(desktop): progress 2025-12-15 10:20:17 -06:00
Adam 82c4755fb0 wip(desktop): progress 2025-12-15 10:20:17 -06:00
Adam 40572eeba4 wip(desktop): progress 2025-12-15 10:20:17 -06:00
Adam d81d63045a wip(desktop): session turn state consolidation 2025-12-15 10:20:16 -06:00
Adam ece3bfd93d wip(desktop): progress 2025-12-15 10:20:16 -06:00
Adam acd91bddf7 wip(desktop): progress 2025-12-15 10:20:16 -06:00
Adam 3a14ca044c wip(desktop): progress 2025-12-15 10:20:16 -06:00
Adam d66d806700 wip(desktop): progress 2025-12-15 10:20:16 -06:00
Adam e9b95b2e91 wip(desktop): progress 2025-12-15 10:20:15 -06:00
opencode 56dde2cc83 release: v1.0.154 2025-12-15 16:01:15 +00:00
Dax Raad d2ce368a3f ci: update publish workflow concurrency to include version inputs and upgrade ubuntu runner to 24.04 2025-12-15 10:14:20 -05:00
GitHub Action f492122d59 chore: format code 2025-12-15 14:59:05 +00:00
Dax Raad b0f77da56c core: reorganize agent configuration to separate primary agents (build, plan) from subagents 2025-12-15 09:58:23 -05:00
Dax Raad 274b86b19b ci: fix AppImage build hanging by using portable appimage format
- Add appimage target back to tauri.conf.json
- Force reinstall tauri-cli from feat/truly-portable-appimage branch
- Add 10 minute timeout to prevent indefinite hangs
- Add logging to verify correct tauri-cli version is installed

This fixes the issue where AppImage builds would hang at 'Running input plugin: gtk' by using the new portable appimage format that bypasses linuxdeploy entirely.
2025-12-15 09:47:19 -05:00
René 2ca118db59 docs: Fix Wakatime repository link in ecosystem.mdx (#5552)
Co-authored-by: Github Action <action@github.com>
2025-12-15 08:32:06 -06:00
David Hill a0c0e2b5c3 fix: add tooltip to close review tab 2025-12-15 14:22:57 +00:00
David Hill d43fbec12d fix: prompt input using border shadow 2025-12-15 14:14:10 +00:00
David Hill bb426112ed fix: replace agents dropdown with shadow border 2025-12-15 14:09:47 +00:00
David Hill d2217bb825 fix: fix width and padding on agent select
- conditionally hide the role=presentation element because it was adding extra gap before the first agent
2025-12-15 14:05:24 +00:00
David Hill ac495bd351 fix: hide prompt input send tooltip when the send button is disabled 2025-12-15 13:47:50 +00:00
David Hill b913eb7acc fix: avatar and icon size in sidebar 2025-12-15 13:21:59 +00:00
David Hill ea65a91b2e fix: remove blue border from prompt input 2025-12-15 13:11:09 +00:00
GitHub Action ed6d749104 ignore: update download stats 2025-12-15 2025-12-15 12:05:05 +00:00
René 9eefcd1b41 Provider fix, anthropic Errorhandling if empty image file is read (#5521) 2025-12-14 23:56:47 -06:00
Nalin Singh 7c1124199e fix: input lip visibility for transparent themes (#5544) 2025-12-14 23:10:33 -06:00
Spoon 5cf126d489 fix(edit): add per-file lock to prevent read-before-write race (#4388) 2025-12-14 23:01:50 -06:00
Aiden Cline 509f7d9617 ignore: fix debug var in last commit 2025-12-14 22:59:30 -06:00
opencode-agent[bot] ae1bf92c81 Add dismiss button to Getting Started box (#5543)
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 <aidenpcline@gmail.com>
2025-12-14 22:58:26 -06:00
DS b021b26e77 feat: restore experimental.chat.messages.transform and add experimental.chat.system.transform hooks (#5542) 2025-12-14 22:51:11 -06:00
Aiden Cline 9555d348de ci: switch model 2025-12-14 22:41:54 -06:00
Brendan Allan 220c564047 tauri: use correct sidecar name 2025-12-15 12:40:49 +08:00
Brendan Allan cf5c0129ac tauri: rename sidecar to opencode-cli 2025-12-15 12:40:14 +08:00
Aiden Cline 543dbe71d2 ci: smart oc 2025-12-14 22:36:46 -06:00
Ravi Kumar 54569b5552 fix(session): fix unshare command not clearing share state (#5523) 2025-12-14 22:05:06 -06:00
GitHub Action 6a09861806 chore: format code 2025-12-15 03:42:56 +00:00
Adam 79a4c65313 fix: test 2025-12-14 21:42:17 -06:00
Adam 654534ac71 fix: update sdk 2025-12-14 21:41:20 -06:00
Adam ba16bfdf3d wip(desktop): progress 2025-12-14 21:38:59 -06:00
Adam ad5614bbb9 wip(desktop): progress 2025-12-14 21:38:59 -06:00
Adam dda579c8ad wip(desktop): progress 2025-12-14 21:38:59 -06:00
Adam 4246cdb069 wip(desktop): progress 2025-12-14 21:38:58 -06:00
Adam 7ade6d386d wip(desktop): progress 2025-12-14 21:38:58 -06:00
Adam 2613f44961 wip(desktop): progress 2025-12-14 21:38:58 -06:00
Adam 62ffeb3987 fix(desktop): auto scroll 2025-12-14 21:38:58 -06:00
Adam 4a8e8f537c wip(desktop): progress 2025-12-14 21:38:58 -06:00
Adam a68bee7878 fix(desktop): layout fixes 2025-12-14 21:38:57 -06:00
Mark Jaquith ed33d82535 feat(cli): auto-submit prompt when using --prompt flag (#4510) 2025-12-14 21:06:04 -06:00
Dax Raad 2d63c22d1a fix share link 2025-12-14 22:05:53 -05:00
Dax Raad e22af25076 ci: fix test failures in CI by pre-populating models cache
Tests were failing in CI because the models.json cache file doesn't exist
and the data() macro fallback only works at build time, not runtime.
The preload now pre-fetches models.json and disables the background
refresh to prevent race conditions during test execution.
2025-12-14 21:23:45 -05:00
Dax Raad 622caae9c9 fix desktop updater 2025-12-14 21:12:38 -05:00
Dax fed4776451 LLM cleanup (#5462)
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-12-14 21:11:30 -05:00
Ravi Kumar fdf560c343 fix(tui): --continue selects wrong session (#5513) 2025-12-14 19:50:54 -06:00
GitHub Action fc3ffb2bf9 chore: format code 2025-12-14 23:14:05 +00:00
Martijn Baay 7368342bab feat: add experimental.continue_loop_on_deny config option (#4729)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-12-14 17:13:32 -06:00
Dax Raad c8fc910533 ignore: simplify download page to use GitHub latest redirect URLs 2025-12-14 17:10:59 -05:00
GitHub Action 0f9ef84d55 chore: format code 2025-12-14 22:05:28 +00:00
Dax Raad 74b5c285cf disable app image 2025-12-14 17:04:46 -05:00
opencode a34e67b518 release: v1.0.153 2025-12-14 19:04:01 +00:00
Aiden Cline 0c7f0cfa2e tweak: fallback to provider default for temperature 2025-12-14 11:57:12 -06:00
GitHub Action 10ee6d345b chore: format code 2025-12-14 17:48:34 +00:00
Nalin Singh 48ec68730f fix: ensure input borders are drawn in transparent themes (#5524) 2025-12-14 11:48:02 -06:00
GitHub Action 70e4efe429 chore: format code 2025-12-14 17:46:46 +00:00
Sellers Crisp 92948ed8a4 feat: add server_error, rate_limit, and no_kv_space retry logic to accommodate Foundry API issues (#5527)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-12-14 11:46:14 -06:00
shekohex 6d412d8872 docs: add opencode-pty and opencode-google-antigravity-auth plugins to the echosystem (#5530) 2025-12-14 11:38:53 -06:00
Lawrence Sarpong e6a0a005d6 Add Gleam LSP and formatter (#5514) 2025-12-14 10:51:08 -06:00
Aymvn 90d44751e7 docs: Fix Wakatime link in ecosystem documentation (#5528) 2025-12-14 10:49:35 -06:00
GitHub Action 4d062ba1b2 ignore: update download stats 2025-12-14 2025-12-14 12:04:18 +00:00
Aiden Cline f8bca50f00 rm unnecessary code 2025-12-13 23:45:15 -06:00
Aiden Cline 3d2ef28fa8 add topK function to transform, add temp defaults for glm and minimax 2025-12-13 23:27:11 -06:00
YeonGyu-Kim 210b3e905b fix(ui): guard Node reference for SSR compatibility in isTriggerTitle (#5509) 2025-12-13 22:28:14 -06:00
Brendan Allan 96975ef8d6 tauri: change mainBinaryName to just OpenCode 2025-12-14 12:08:53 +08:00
Brendan Allan b8b998be56 tauri: bring back appimage 2025-12-14 12:00:14 +08:00
Sachnun d8ac35f6e5 fix(tui): open parent session instead of subagent on continue flag (#5503) 2025-12-13 21:09:42 -06:00
Zhou Rui ed1eacfce0 docs: Add opencode-websearch-cited to plugin list (#5501) 2025-12-13 20:28:39 -06:00
Adam 629f475f63 fix: sort models 2025-12-13 20:25:24 -06:00
Adam 43a7c1dd8c fix: use opencode icon 2025-12-13 20:25:24 -06:00
Adam e288ce0fca chore: cleanup 2025-12-13 20:25:24 -06:00
Adam 67b3fcb31a chore: cleanup 2025-12-13 20:25:24 -06:00
Tommy D. Rossi aedb5550a8 fix: limit LSP diagnostics to prevent context window waste (#5480)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-12-13 19:56:26 -06:00
Aiden Cline 1638ffde69 docs: networking 2025-12-13 18:20:44 -06:00
Aiden Cline d4cfbd8219 chore: reduce duplication of field in transform 2025-12-13 18:07:22 -06:00
Adam c7bac83212 chore: cleanup 2025-12-13 16:17:32 -06:00
Adam fc9789d7a7 fix(desktop): archive button 2025-12-13 16:14:31 -06:00
Adam a8957d8d16 fix(desktop): auto scroll 2025-12-13 15:56:12 -06:00
Adam 0660433921 feat(desktop): show richer status when thinking 2025-12-13 15:47:50 -06:00
Adam 1a6f4f1c0d fix: css scroll jitter 2025-12-13 15:36:28 -06:00
Adam 974a24ba02 fix: don't rotate placeholders in session 2025-12-13 15:25:56 -06:00
Adam 5ebe29de1e fix: don't open shell by default 2025-12-13 15:17:22 -06:00
Github Action 6bdf8b1fe1 Update Nix flake.lock and hashes 2025-12-13 21:13:53 +00:00
Adam 5bcc93851c chore: cleanup 2025-12-13 15:12:41 -06:00
Adam d0789632b4 fix(desktop): terminal light mode 2025-12-13 15:12:32 -06:00
Adam a6e297baad feat(desktop): message history 2025-12-13 14:57:24 -06:00
Adam 307af10c8b fix: session turn scroll 2025-12-13 14:57:23 -06:00
Felipe Oduardo Sierra f254cf76d9 add ARM64 Docker image support (#5483) 2025-12-13 13:01:59 -06:00
Github Action b4ffaa21ec Update Nix flake.lock and hashes 2025-12-13 19:01:20 +00:00
Aiden Cline 7bf6f264e4 bump bun version & set flags this time 2025-12-13 13:00:03 -06:00
GitHub Action 7434fbba8e chore: format code 2025-12-13 17:34:07 +00:00
Jan-Niklas W. b7581e01ea docs: fix title for JetBrains ACP config file (#5479) 2025-12-13 11:33:31 -06:00
YeonGyu-Kim b46d4789fc docs: add oh-my-opencode to plugins list (#5481) 2025-12-13 11:33:10 -06:00
GitHub Action 199bd8a9a2 chore: format code 2025-12-13 17:30:48 +00:00
rari404 decf2452c4 feat: add dockerfile language server (#5252)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-12-13 11:30:15 -06:00
GitHub Action d8663a44c2 ignore: update download stats 2025-12-13 2025-12-13 12:04:19 +00:00
rari404 8917a4c609 feat: add texlab language server and latexindent formatter (#5251) 2025-12-12 23:50:09 -06:00
GitHub Action 5d7a52f8b8 chore: format code 2025-12-13 02:09:41 +00:00
Jan-Niklas W. b7b827c5bd docs: JetBrains IDEs to ACP config docs page (#5465) 2025-12-12 20:09:08 -06:00
Matt Silverlock 613e082358 github: support GITHUB_TOKEN + skip OIDC (#5459) 2025-12-12 19:55:46 -06:00
Charles Cooper b6856bd593 fix: add --session flag to attach command (#5460)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-12-12 19:45:28 -06:00
David Hill 7cb5a77ba6 fix: mute the project path in the sidebar that proceeds the final directory 2025-12-12 23:45:39 +00:00
David Hill cd9898a565 Merge branch 'dev' of https://github.com/sst/opencode into dev 2025-12-12 23:25:11 +00:00
Dax Raad a4ffa869cc fix 2025-12-12 18:15:31 -05:00
David Hill dbc84ff4c3 Merge branch 'dev' of https://github.com/sst/opencode into dev 2025-12-12 22:50:50 +00:00
David Hill c11ea3fd92 fix: mute the whole prompt area when leader key is active 2025-12-12 22:50:48 +00:00
GitHub Action 3c3a0f8afb chore: format code 2025-12-12 22:48:43 +00:00
Aiden Cline b93614cb81 docs: add env vars sections 2025-12-12 16:47:50 -06:00
339 changed files with 8284 additions and 3955 deletions
-63
View File
@@ -1,63 +0,0 @@
name: Auto-label TUI Issues
on:
issues:
types: [opened]
jobs:
auto-label:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
issues: write
steps:
- name: Auto-label and assign issues
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const issue = context.payload.issue;
const title = issue.title;
const description = issue.body || '';
// Check for "opencode web" keyword
const webPattern = /(opencode web)/i;
const isWebRelated = webPattern.test(title) || webPattern.test(description);
// Check for version patterns like v1.0.x or 1.0.x
const versionPattern = /[v]?1\.0\./i;
const isVersionRelated = versionPattern.test(title) || versionPattern.test(description);
// Check for "nix" keyword
const nixPattern = /\bnix\b/i;
const isNixRelated = nixPattern.test(title) || nixPattern.test(description);
const labels = [];
if (isWebRelated) {
labels.push('web');
// Assign to adamdotdevin
await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
assignees: ['adamdotdevin']
});
} else if (isVersionRelated) {
// Only add opentui if NOT web-related
labels.push('opentui');
}
if (isNixRelated) {
labels.push('nix');
}
if (labels.length > 0) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: labels
});
}
+2
View File
@@ -16,6 +16,8 @@ jobs:
with:
fetch-depth: 1
- uses: ./.github/actions/setup-bun
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
+1 -1
View File
@@ -31,4 +31,4 @@ jobs:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_PERMISSION: '{"bash": "deny"}'
with:
model: opencode/claude-haiku-4-5
model: opencode/claude-opus-4-5
+34 -4
View File
@@ -21,7 +21,7 @@ on:
required: false
type: string
concurrency: ${{ github.workflow }}-${{ github.ref }}
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version || inputs.bump }}
permissions:
id-token: write
@@ -31,7 +31,7 @@ permissions:
jobs:
publish:
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'sst/opencode' && github.ref == 'refs/heads/dev'
if: github.repository == 'sst/opencode'
steps:
- uses: actions/checkout@v3
with:
@@ -64,6 +64,12 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- uses: actions/setup-node@v4
with:
node-version: "24"
@@ -103,6 +109,7 @@ jobs:
- uses: actions/checkout@v3
with:
fetch-depth: 0
ref: ${{ needs.publish.outputs.tagName }}
- uses: apple-actions/import-codesign-certs@v2
if: ${{ runner.os == 'macOS' }}
@@ -158,12 +165,18 @@ jobs:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
OPENCODE_RELEASE_TAG: ${{ needs.publish.outputs.tagName }}
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
- run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage
- name: Install tauri-cli from portable appimage branch
if: contains(matrix.settings.host, 'ubuntu')
run: |
cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force
echo "Installed tauri-cli version:"
cargo tauri --version
- name: Build and upload artifacts
timeout-minutes: 20
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -180,8 +193,25 @@ jobs:
projectPath: packages/tauri
uploadWorkflowArtifacts: true
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
args: --target ${{ matrix.settings.target }}
args: --target ${{ matrix.settings.target }} --config src-tauri/tauri.prod.conf.json
updaterJsonPreferNsis: true
releaseId: ${{ needs.publish.outputs.releaseId }}
tagName: ${{ needs.publish.outputs.tagName }}
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
releaseDraft: true
publish-release:
needs:
- publish
- publish-tauri
if: needs.publish.outputs.tagName
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
ref: ${{ needs.publish.outputs.tagName }}
- run: gh release edit ${{ needs.publish.outputs.tagName }} --draft=false
env:
GH_TOKEN: ${{ github.token }}
@@ -0,0 +1,29 @@
name: release-github-action
on:
push:
branches:
- dev
paths:
- "github/**"
concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions:
contents: write
jobs:
release:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: git fetch --force --tags
- name: Release
run: |
git config --global user.email "opencode@sst.dev"
git config --global user.name "opencode"
./github/script/release
+2
View File
@@ -29,6 +29,8 @@ jobs:
with:
fetch-depth: 1
- uses: ./.github/actions/setup-bun
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
+37
View File
@@ -0,0 +1,37 @@
name: Issue Triage
on:
issues:
types: [opened]
jobs:
triage:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
- name: Triage issue
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
run: |
opencode run --agent triage "The following issue was just opened, triage it:
Title: $ISSUE_TITLE
$ISSUE_BODY"
+79
View File
@@ -0,0 +1,79 @@
---
mode: primary
hidden: true
model: opencode/claude-haiku-4-5
tools:
"*": false
"github-triage": true
---
You are a triage agent responsible for triaging github issues.
Use your github-triage tool to triage issues.
## Labels
### windows
Use for any issue that is mentions windows (the OS). Be sure they are saying that they are on windows.
- Use if they mention WSL too
#### perf
Performance-related issues:
- Slow performance
- High RAM usage
- High CPU usage
**Only** add if it's likely a RAM or CPU issue. **Do not** add for LLM slowness.
#### desktop
Desktop app issues:
- `opencode web` command
- The desktop app itself
**Only** add if it's specifically about the Desktop application or `opencode web` view. **Do not** add for terminal, TUI, or general opencode issues.
#### nix
**Only** add if the issue explicitly mentions nix.
#### zen
**Only** add if the issue mentions "zen" or "opencode zen". Zen is our gateway for coding models. **Do not** add for other gateways or inference providers.
If the issue doesn't have "zen" in it then don't add zen label
#### docs
Add if the issue requests better documentation or docs updates.
#### opentui
TUI issues potentially caused by our underlying TUI library:
- Keybindings not working
- Scroll speed issues (too fast/slow/laggy)
- Screen flickering
- Crashes with opentui in the log
**Do not** add for general TUI bugs.
---
When assigning to people here are the following rules:
adamdotdev:
ONLY assign adam if the issue will have the "desktop" label.
fwang:
ONLY assign fwang if the issue will have the "zen" label.
jayair:
ONLY assign jayair if the issue will have the "docs" label.
In all other cases use best judgment. Avoid assigning to kommander needlessly, when in doubt assign to rekram1-node.
+1
View File
@@ -1,6 +1,7 @@
---
description: git commit and push
model: opencode/glm-4.6
subtask: true
---
commit and push
+4
View File
@@ -0,0 +1,4 @@
declare module "*.txt" {
const content: string
export default content
}
+4
View File
@@ -10,4 +10,8 @@
"options": {},
},
},
"mcp": {},
"tools": {
"github-triage": false,
},
}
+66
View File
@@ -0,0 +1,66 @@
/// <reference path="../env.d.ts" />
import { Octokit } from "@octokit/rest"
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-triage.txt"
function getIssueNumber(): number {
const issue = parseInt(process.env.ISSUE_NUMBER ?? "", 10)
if (!issue) throw new Error("ISSUE_NUMBER env var not set")
return issue
}
export default tool({
description: DESCRIPTION,
args: {
assignee: tool.schema
.enum(["thdxr", "adamdotdevin", "rekram1-node", "fwang", "jayair", "kommander"])
.describe("The username of the assignee")
.default("rekram1-node"),
labels: tool.schema
.array(tool.schema.enum(["nix", "opentui", "perf", "desktop", "zen", "docs", "windows"]))
.describe("The labels(s) to add to the issue")
.default([]),
},
async execute(args) {
const issue = getIssueNumber()
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN })
const owner = "sst"
const repo = "opencode"
const results: string[] = []
if (args.assignee === "adamdotdevin" && !args.labels.includes("desktop")) {
throw new Error("Only desktop issues should be assigned to adamdotdevin")
}
if (args.assignee === "fwang" && !args.labels.includes("zen")) {
throw new Error("Only zen issues should be assigned to fwang")
}
if (args.assignee === "kommander" && !args.labels.includes("opentui")) {
throw new Error("Only opentui issues should be assigned to kommander")
}
await octokit.rest.issues.addAssignees({
owner,
repo,
issue_number: issue,
assignees: [args.assignee],
})
results.push(`Assigned @${args.assignee} to issue #${issue}`)
const labels: string[] = args.labels.map((label) => (label === "desktop" ? "web" : label))
if (labels.length > 0) {
await octokit.rest.issues.addLabels({
owner,
repo,
issue_number: issue,
labels,
})
results.push(`Added labels: ${args.labels.join(", ")}`)
}
return results.join("\n")
},
})
+88
View File
@@ -0,0 +1,88 @@
Use this tool to assign and/or label a Github issue.
You can assign the following users:
- thdxr
- adamdotdevin
- fwang
- jayair
- kommander
- rekram1-node
You can use the following labels:
- nix
- opentui
- perf
- web
- zen
- docs
Always try to assign an issue, if in doubt, assign rekram1-node to it.
## Breakdown of responsibilities:
### thdxr
Dax is responsible for managing core parts of the application, for large feature requests, api changes, or things that require significant changes to the codebase assign him.
This relates to OpenCode server primarily but has overlap with just about anything
### adamdotdevin
Adam is responsible for managing the Desktop/Web app. If there is an issue relating to the desktop app or `opencode web` command. Assign him.
### fwang
Frank is responsible for managing Zen, if you see complaints about OpenCode Zen, maybe it's the dashboard, the model quality, billing issues, etc. Assign him to the issue.
### jayair
Jay is responsible for documentation. If there is an issue relating to documentation assign him.
### kommander
Sebastian is responsible for managing an OpenTUI (a library for building terminal user interfaces). OpenCode's TUI is built with OpenTUI. If there are issues about:
- random characters on screen
- keybinds not working on different terminals
- general terminal stuff
Then assign the issue to Him.
### rekram1-node
ALL BUGS SHOULD BE assigned to rekram1-node unless they have the `opentui` label.
Assign Aiden to an issue as a catch all, if you can't assign anyone else. Most of the time this will be bugs/polish things.
If no one else makes sense to assign, assign rekram1-node to it.
Always assign to aiden if the issue mentions "acp", "zed", or model performance issues
## Breakdown of Labels:
### nix
Any issue that mentions nix, or nixos should have a nix label
### opentui
Anything relating to the TUI itself should have an opentui label
### perf
Anything related to slow performance, high ram, high cpu usage, or any other performance related issue should have a perf label
### desktop
Anything related to `opencode web` command or the desktop app should have a desktop label. Never add this label for anything terminal/tui related
### zen
Anything related to OpenCode Zen, billing, or model quality from Zen should have a zen label
### docs
Anything related to the documentation should have a docs label
### windows
Use for any issue that involves the windows OS
+16
View File
@@ -37,6 +37,22 @@ nix run nixpkgs#opencode # or github:sst/opencode for latest dev branc
> [!TIP]
> Remove versions older than 0.1.x before installing.
### Desktop App (BETA)
OpenCode is also available as a desktop application. Download directly from the [releases page](https://github.com/sst/opencode/releases) or [opencode.ai/download](https://opencode.ai/download).
| Platform | Download |
| --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm`, or AppImage |
```bash
# macOS (Homebrew)
brew install --cask opencode-desktop
```
#### Installation Directory
The install script respects the following priority order for the installation path:
+5
View File
@@ -168,3 +168,8 @@
| 2025-12-10 | 1,025,891 (+14,403) | 991,708 (+17,786) | 2,017,599 (+32,189) |
| 2025-12-11 | 1,045,110 (+19,219) | 1,010,559 (+18,851) | 2,055,669 (+38,070) |
| 2025-12-12 | 1,061,340 (+16,230) | 1,030,838 (+20,279) | 2,092,178 (+36,509) |
| 2025-12-13 | 1,073,561 (+12,221) | 1,044,608 (+13,770) | 2,118,169 (+25,991) |
| 2025-12-14 | 1,082,042 (+8,481) | 1,052,425 (+7,817) | 2,134,467 (+16,298) |
| 2025-12-15 | 1,093,632 (+11,590) | 1,059,078 (+6,653) | 2,152,710 (+18,243) |
| 2025-12-16 | 1,120,477 (+26,845) | 1,078,022 (+18,944) | 2,198,499 (+45,789) |
| 2025-12-17 | 1,151,067 (+30,590) | 1,097,661 (+19,639) | 2,248,728 (+50,229) |
+88 -75
View File
@@ -6,6 +6,7 @@
"name": "opencode",
"dependencies": {
"@aws-sdk/client-s3": "3.933.0",
"@octokit/rest": "22.0.1",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"typescript": "catalog:",
@@ -20,7 +21,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.0.152",
"version": "1.0.165",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -48,7 +49,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.0.152",
"version": "1.0.165",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -75,7 +76,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.0.152",
"version": "1.0.165",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -99,7 +100,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.0.152",
"version": "1.0.165",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -123,7 +124,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.0.152",
"version": "1.0.165",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -133,9 +134,10 @@
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "4.3.3",
"@solid-primitives/storage": "catalog:",
"@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
@@ -169,11 +171,11 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.0.152",
"version": "1.0.165",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/precision-diffs": "catalog:",
"@pierre/diffs": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@solidjs/start": "catalog:",
@@ -198,10 +200,10 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.0.152",
"version": "1.0.165",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "22.0.0",
"@octokit/rest": "catalog:",
"hono": "catalog:",
"jose": "6.0.11",
},
@@ -214,7 +216,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.0.152",
"version": "1.0.165",
"bin": {
"opencode": "./bin/opencode",
},
@@ -237,17 +239,17 @@
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.15.1",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "22.0.0",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.0.0-20251211-4403a69a",
"@opentui/solid": "0.0.0-20251211-4403a69a",
"@opentui/core": "0.1.61",
"@opentui/solid": "0.1.61",
"@parcel/watcher": "2.5.1",
"@pierre/precision-diffs": "catalog:",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
@@ -306,7 +308,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.0.152",
"version": "1.0.165",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -326,7 +328,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.0.152",
"version": "1.0.165",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -337,7 +339,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.0.152",
"version": "1.0.165",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -350,9 +352,10 @@
},
"packages/tauri": {
"name": "@opencode-ai/tauri",
"version": "1.0.152",
"version": "1.0.165",
"dependencies": {
"@opencode-ai/desktop": "workspace:*",
"@solid-primitives/storage": "catalog:",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2",
"@tauri-apps/plugin-opener": "^2",
@@ -375,12 +378,12 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.0.152",
"version": "1.0.165",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/precision-diffs": "catalog:",
"@pierre/diffs": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/bounds": "0.1.3",
"@solid-primitives/resize-observer": "2.1.3",
@@ -410,7 +413,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.0.152",
"version": "1.0.165",
"dependencies": {
"zod": "catalog:",
},
@@ -421,7 +424,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.0.152",
"version": "1.0.165",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -469,15 +472,17 @@
"@cloudflare/workers-types": "4.20251008.0",
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/precision-diffs": "0.6.1",
"@pierre/diffs": "1.0.0-beta.3",
"@solid-primitives/storage": "4.3.3",
"@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"@tailwindcss/vite": "4.1.11",
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.3",
"@types/bun": "1.3.4",
"@types/luxon": "3.7.1",
"@types/node": "22.13.9",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
@@ -1095,11 +1100,11 @@
"@octokit/openapi-types": ["@octokit/openapi-types@25.1.0", "", {}, "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA=="],
"@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@13.2.1", "", { "dependencies": { "@octokit/types": "^15.0.1" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-Tj4PkZyIL6eBMYcG/76QGsedF0+dWVeLhYprTmuFVVxzDW7PQh23tM0TP0z+1MvSkxB29YFZwnUX+cXfTiSdyw=="],
"@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="],
"@octokit/plugin-request-log": ["@octokit/plugin-request-log@6.0.0", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q=="],
"@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@16.1.1", "", { "dependencies": { "@octokit/types": "^15.0.1" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-VztDkhM0ketQYSh5Im3IcKWFZl7VIrrsCaHbDINkdYeiiAsJzjhS2xRFCSJgfN6VOcsoW4laMtsmf3HcNqIimg=="],
"@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@17.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw=="],
"@octokit/plugin-retry": ["@octokit/plugin-retry@3.0.9", "", { "dependencies": { "@octokit/types": "^6.0.3", "bottleneck": "^2.15.3" } }, "sha512-r+fArdP5+TG6l1Rv/C9hVoty6tldw6cE2pRHNGmFPdyfrc696R6JjrQ3d7HdVqGwuzfyrcaLAKD7K8TX8aehUQ=="],
@@ -1107,7 +1112,7 @@
"@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
"@octokit/rest": ["@octokit/rest@22.0.0", "", { "dependencies": { "@octokit/core": "^7.0.2", "@octokit/plugin-paginate-rest": "^13.0.1", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^16.0.0" } }, "sha512-z6tmTu9BTnw51jYGulxrlernpsQYXpui1RK21vmXn8yF5bp6iX16yfTtJYGK5Mh1qDkvDOmp2n8sRMcQmR8jiA=="],
"@octokit/rest": ["@octokit/rest@22.0.1", "", { "dependencies": { "@octokit/core": "^7.0.6", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0" } }, "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw=="],
"@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="],
@@ -1155,21 +1160,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.0.0-20251211-4403a69a", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.0.0-20251211-4403a69a", "@opentui/core-darwin-x64": "0.0.0-20251211-4403a69a", "@opentui/core-linux-arm64": "0.0.0-20251211-4403a69a", "@opentui/core-linux-x64": "0.0.0-20251211-4403a69a", "@opentui/core-win32-arm64": "0.0.0-20251211-4403a69a", "@opentui/core-win32-x64": "0.0.0-20251211-4403a69a", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wTZKcokyU9yiDqyC0Pvf9eRSdT73s4Ynerkit/z8Af++tynqrTlZHZCXK3o42Ff7itCSILmijcTU94n69aEypA=="],
"@opentui/core": ["@opentui/core@0.1.61", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.61", "@opentui/core-darwin-x64": "0.1.61", "@opentui/core-linux-arm64": "0.1.61", "@opentui/core-linux-x64": "0.1.61", "@opentui/core-win32-arm64": "0.1.61", "@opentui/core-win32-x64": "0.1.61", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-WrVbdki0tnsgmWCB3Iix6n8eXGXUheTqr/tcnBN7gLA/TqT9udcX+DW3/qRdgtTNJS1sVBVeuwSTYU3eqDSUJQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.0.0-20251211-4403a69a", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VAYjTa+Eiauy8gETXadD8y0PE6ppnKasDK1X354VoexZiWFR3r7rkL+TfDfk7whhqXDYyT44JDT1QmCAhVXRzQ=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.61", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zX7EK8PwBJFwsZ2tDnScLFD0GbBfHE7sqpzGDXP2luMnBZJ0OOO95a4Hzu9dQWqxEr4RgfGDT8uIRhgimKNQEg=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.0.0-20251211-4403a69a", "", { "os": "darwin", "cpu": "x64" }, "sha512-n9oVMpsojlILj1soORZzZ2Mjh8Zl73ZNcY7ot0iRmOjBDccrjDTsqKfxoGjKNd/xJSphLeu1LYGlcI5O5OczWQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.61", "", { "os": "darwin", "cpu": "x64" }, "sha512-xfvl8EnyN0XwlYpyTskVhHOpbMdgt++ntcuTh7M7IEFYQGzJux19NBwJl17mOxB1McG+KTa7kNx5/zu0VB9eVQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.0.0-20251211-4403a69a", "", { "os": "linux", "cpu": "arm64" }, "sha512-vf4eUjPMI4ANitK4MpTGenZFddKgQD/K21aN6cZjusnH3mTEJAoIR7GbNtMdz3qclU43ajpzTID9sAwhshwdVQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.61", "", { "os": "linux", "cpu": "arm64" }, "sha512-Ghg7j4H6bz7CLxhgDcWx3Ann3AblDIjKFUu4vFrVysuiwfmDHwdKm8awLj8tnmC/0y8juG4ODUQbR0BXBIkE+Q=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.0.0-20251211-4403a69a", "", { "os": "linux", "cpu": "x64" }, "sha512-61635Up0YvVJ8gZ2eMiL1c8OfA+U6wAzT++LoaurNjbmsUAlKHws6MZdqTLw7aspJJVGsRFbA6d1Y+gXFxbDrQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.61", "", { "os": "linux", "cpu": "x64" }, "sha512-Xs9czMEOuHtnX4tigC4fNb1MU7+Gaohbk+k4teraulIgYZf19nRHIKNvXissDjOfqvOGygCkxMQIG0zeUFsPEA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.0.0-20251211-4403a69a", "", { "os": "win32", "cpu": "arm64" }, "sha512-3lUddTJGKZ6uU388eU79MY//IEbgGENCITetDrrRp7v9L1AxMntE1ihf6HniziwBvKKJcsUfqLiJWcq0WPZw2w=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.61", "", { "os": "win32", "cpu": "arm64" }, "sha512-2CYAEPqArJqE36LkSRAs0csRzWwVJY99S/7EuY7abBm58BIL6RUw5kSw1r75oDo4I3W6v6WwW0u8B5Ik98m0Kg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.0.0-20251211-4403a69a", "", { "os": "win32", "cpu": "x64" }, "sha512-Xwc1gqYsn8UZNTzNKkigZozAhBNBGbfX2B/I/aSbyqL0h8+XIInOodI0urzJWc0B6aEv/IDiT6Rm3coXFikLIg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.61", "", { "os": "win32", "cpu": "x64" }, "sha512-c0OK5YwcKH51Qj6wPmwTZP3X8LHA0I0dKz4fO4mOh4f+OqgU9WOG4hpbf7lv0bVlHoTvgP4zDUsjmtIVA8l6Lg=="],
"@opentui/solid": ["@opentui/solid@0.0.0-20251211-4403a69a", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.0.0-20251211-4403a69a", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-vuLppAdd1Qgaqhie3q2TuEr+8udjT4d8uVg5arvCe1AUDVs19I8kvadVCfzGUVmtXgFIOEakbiv6AxDq5v9Zig=="],
"@opentui/solid": ["@opentui/solid@0.1.61", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.61", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-CiZHduIoeABoS0ev+eGeHA/LiRl/SpdL6io4jrwiwFi/rToKtc7YgJ8MWxIgeHScHUbpQnIr1v7jzsGI3DAYvw=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1285,7 +1290,7 @@
"@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
"@pierre/precision-diffs": ["@pierre/precision-diffs@0.6.1", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/transformers": "3.15.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.15.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-HXafRSOly6B0rRt6fuP0yy1MimHJMQ2NNnBGcIHhHwsgK4WWs+SBWRWt1usdgz0NIuSgXdIyQn8HY3F1jKyDBQ=="],
"@pierre/diffs": ["@pierre/diffs@1.0.0-beta.3", "", { "dependencies": { "@shikijs/core": "3.19.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/transformers": "3.19.0", "diff": "8.0.2", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.19.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-W3dFWdFOBZ9OskGSOgN16aci8dsUyAavCxz3ZvbbVLTb2qRzMZ7H90qdfON13/N2l1HTyh84lkrCs1/sDvnRjQ=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
@@ -1703,7 +1708,7 @@
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
"@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="],
"@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
@@ -2009,7 +2014,7 @@
"bun-pty": ["bun-pty@0.4.2", "", {}, "sha512-sHImDz6pJDsHAroYpC9ouKVgOyqZ7FP3N+stX5IdMddHve3rf9LIZBDomQcXrACQ7sQDNuwZQHG8BKR7w8krkQ=="],
"bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
"bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
@@ -4079,9 +4084,9 @@
"@octokit/oauth-methods/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
"@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@15.0.2", "", { "dependencies": { "@octokit/openapi-types": "^26.0.0" } }, "sha512-rR+5VRjhYSer7sC51krfCctQhVTmjyUMAaShfPB8mscVa8tSoLyon3coxQmXu0ahJoLVWl8dSGD/3OGZlFV44Q=="],
"@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@15.0.2", "", { "dependencies": { "@octokit/openapi-types": "^26.0.0" } }, "sha512-rR+5VRjhYSer7sC51krfCctQhVTmjyUMAaShfPB8mscVa8tSoLyon3coxQmXu0ahJoLVWl8dSGD/3OGZlFV44Q=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
"@octokit/plugin-retry/@octokit/types": ["@octokit/types@6.41.0", "", { "dependencies": { "@octokit/openapi-types": "^12.11.0" } }, "sha512-eJ2jbzjdijiL3B4PrSQaSjuF2sPEQPVCPzBvTHJD9Nz+9dw2SGH4K4xeQJ77YfTq5bRQ+bD8wT11JbeDPmxmGg=="],
@@ -4093,6 +4098,8 @@
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@opencode-ai/function/@octokit/rest": ["@octokit/rest@22.0.0", "", { "dependencies": { "@octokit/core": "^7.0.2", "@octokit/plugin-paginate-rest": "^13.0.1", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^16.0.0" } }, "sha512-z6tmTu9BTnw51jYGulxrlernpsQYXpui1RK21vmXn8yF5bp6iX16yfTtJYGK5Mh1qDkvDOmp2n8sRMcQmR8jiA=="],
"@opencode-ai/tauri/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/types": "3.4.2" } }, "sha512-I5baLVi/ynLEOZoWSAMlACHNnG+yw5HDmse0oe+GW6U1u+ULdEB3UHiVWaHoJSSONV7tlcVxuaMy74sREDkSvg=="],
@@ -4111,11 +4118,13 @@
"@parcel/watcher/node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
"@pierre/precision-diffs/@shikijs/core": ["@shikijs/core@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-8TOG6yG557q+fMsSVa8nkEDOZNTSxjbbR8l6lF2gyr6Np+jrPlslqDxQkN6rMXCECQ3isNPZAGszAfYoJOPGlg=="],
"@pierre/diffs/@shikijs/core": ["@shikijs/core@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-L7SrRibU7ZoYi1/TrZsJOFAnnHyLTE1SwHG1yNWjZIVCqjOEmCSuK2ZO9thnRbJG6TOkPp+Z963JmpCNw5nzvA=="],
"@pierre/precision-diffs/@shikijs/transformers": ["@shikijs/transformers@3.15.0", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/types": "3.15.0" } }, "sha512-Hmwip5ovvSkg+Kc41JTvSHHVfCYF+C8Cp1omb5AJj4Xvd+y9IXz2rKJwmFRGsuN0vpHxywcXJ1+Y4B9S7EG1/A=="],
"@pierre/diffs/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-ZfWJNm2VMhKkQIKT9qXbs76RRcT0SF/CAvEz0+RkpUDAoDaCx0uFdCGzSRiD9gSlhm6AHkjdieOBJMaO2eC1rQ=="],
"@pierre/precision-diffs/shiki": ["shiki@3.15.0", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/engine-javascript": "3.15.0", "@shikijs/engine-oniguruma": "3.15.0", "@shikijs/langs": "3.15.0", "@shikijs/themes": "3.15.0", "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw=="],
"@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.19.0", "", { "dependencies": { "@shikijs/core": "3.19.0", "@shikijs/types": "3.19.0" } }, "sha512-e6vwrsyw+wx4OkcrDbL+FVCxwx8jgKiCoXzakVur++mIWVcgpzIi8vxf4/b4dVTYrV/nUx5RjinMf4tq8YV8Fw=="],
"@pierre/diffs/shiki": ["shiki@3.19.0", "", { "dependencies": { "@shikijs/core": "3.19.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/engine-oniguruma": "3.19.0", "@shikijs/langs": "3.19.0", "@shikijs/themes": "3.19.0", "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-77VJr3OR/VUZzPiStyRhADmO2jApMM0V2b1qf0RpfWya8Zr1PeZev5AEpPGAAKWdiYUtcZGBE4F5QvJml1PvWA=="],
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
@@ -4283,6 +4292,8 @@
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.27", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bpYruxVLhrTbVH6CCq48zMJNeHu6FmHtEedl9FXckEgcIEAi036idFhJlcRwC1jNCwlacbzb8dPD7OAH1EKJaQ=="],
"opencode/@octokit/rest": ["@octokit/rest@22.0.0", "", { "dependencies": { "@octokit/core": "^7.0.2", "@octokit/plugin-paginate-rest": "^13.0.1", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^16.0.0" } }, "sha512-z6tmTu9BTnw51jYGulxrlernpsQYXpui1RK21vmXn8yF5bp6iX16yfTtJYGK5Mh1qDkvDOmp2n8sRMcQmR8jiA=="],
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
"opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
@@ -4295,10 +4306,6 @@
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
"opentui-spinner/@opentui/core": ["@opentui/core@0.1.60", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.60", "@opentui/core-darwin-x64": "0.1.60", "@opentui/core-linux-arm64": "0.1.60", "@opentui/core-linux-x64": "0.1.60", "@opentui/core-win32-arm64": "0.1.60", "@opentui/core-win32-x64": "0.1.60", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-28jphd0AJo48uvEuKXcT9pJhgAu8I2rEJhPt25cc5ipJ2iw/eDk1uoxrbID80MPDqgOEzN21vXmzXwCd6ao+hg=="],
"opentui-spinner/@opentui/solid": ["@opentui/solid@0.1.60", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.60", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-pn91stzAHNGWaNL6h39q55bq3G1/DLqxKtT3wVsRAV68dHfPpwmqikX1nEJZK8OU84ZTPS9Ly9fz8po2Mot2uQ=="],
"p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"parse-bmfont-xml/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
@@ -4641,9 +4648,9 @@
"@octokit/oauth-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@26.0.0", "", {}, "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA=="],
"@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@26.0.0", "", {}, "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA=="],
"@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@octokit/plugin-retry/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@12.11.0", "", {}, "sha512-VsXyi8peyRq9PqIz/tpqiL2w3w80OgVMwBHltTml3LmVvXiphgeqmY9mvBw9Wu7e0QWk/fqD37ux8yP5uVekyQ=="],
@@ -4651,6 +4658,10 @@
"@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@opencode-ai/function/@octokit/rest/@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@13.2.1", "", { "dependencies": { "@octokit/types": "^15.0.1" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-Tj4PkZyIL6eBMYcG/76QGsedF0+dWVeLhYprTmuFVVxzDW7PQh23tM0TP0z+1MvSkxB29YFZwnUX+cXfTiSdyw=="],
"@opencode-ai/function/@octokit/rest/@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@16.1.1", "", { "dependencies": { "@octokit/types": "^15.0.1" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-VztDkhM0ketQYSh5Im3IcKWFZl7VIrrsCaHbDINkdYeiiAsJzjhS2xRFCSJgfN6VOcsoW4laMtsmf3HcNqIimg=="],
"@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.4.2", "", { "dependencies": { "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-AG8vnSi1W2pbgR2B911EfGqtLE9c4hQBYkv/x7Z+Kt0VxhgQKcW7UNDVYsu9YxwV6u+OJrvdJrMq6DNWoBjihQ=="],
"@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.4.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zHC1l7L+eQlDXLnxvM9R91Efh2V4+rN3oMVS2swCBssbj2U/FBwybD1eeLaq8yl/iwT+zih8iUbTBCgGZOYlVg=="],
@@ -4669,19 +4680,19 @@
"@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@pierre/precision-diffs/@shikijs/core/@shikijs/types": ["@shikijs/types@3.15.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw=="],
"@pierre/diffs/@shikijs/core/@shikijs/types": ["@shikijs/types@3.19.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="],
"@pierre/precision-diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.15.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw=="],
"@pierre/diffs/@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.19.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="],
"@pierre/precision-diffs/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-ZedbOFpopibdLmvTz2sJPJgns8Xvyabe2QbmqMTz07kt1pTzfEvKZc5IqPVO/XFiEbbNyaOpjPBkkr1vlwS+qg=="],
"@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.19.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="],
"@pierre/precision-diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA=="],
"@pierre/diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1hRxtYIJfJSZeM5ivbUXv9hcJP3PWRo5prG/V2sWwiubUKTa+7P62d2qxCW8jiVFX4pgRHhnHNp+qeR7Xl+6kg=="],
"@pierre/precision-diffs/shiki/@shikijs/langs": ["@shikijs/langs@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0" } }, "sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A=="],
"@pierre/diffs/shiki/@shikijs/langs": ["@shikijs/langs@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0" } }, "sha512-dBMFzzg1QiXqCVQ5ONc0z2ebyoi5BKz+MtfByLm0o5/nbUu3Iz8uaTCa5uzGiscQKm7lVShfZHU1+OG3t5hgwg=="],
"@pierre/precision-diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.15.0", "", { "dependencies": { "@shikijs/types": "3.15.0" } }, "sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ=="],
"@pierre/diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0" } }, "sha512-H36qw+oh91Y0s6OlFfdSuQ0Ld+5CgB/VE6gNPK+Hk4VRbVG/XQgkjnt4KzfnnoO6tZPtKJKHPjwebOCfjd6F8A=="],
"@pierre/precision-diffs/shiki/@shikijs/types": ["@shikijs/types@3.15.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw=="],
"@pierre/diffs/shiki/@shikijs/types": ["@shikijs/types@3.19.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="],
"@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
@@ -4865,6 +4876,10 @@
"opencode/@ai-sdk/openai-compatible/@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=="],
"opencode/@octokit/rest/@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@13.2.1", "", { "dependencies": { "@octokit/types": "^15.0.1" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-Tj4PkZyIL6eBMYcG/76QGsedF0+dWVeLhYprTmuFVVxzDW7PQh23tM0TP0z+1MvSkxB29YFZwnUX+cXfTiSdyw=="],
"opencode/@octokit/rest/@octokit/plugin-rest-endpoint-methods": ["@octokit/plugin-rest-endpoint-methods@16.1.1", "", { "dependencies": { "@octokit/types": "^15.0.1" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-VztDkhM0ketQYSh5Im3IcKWFZl7VIrrsCaHbDINkdYeiiAsJzjhS2xRFCSJgfN6VOcsoW4laMtsmf3HcNqIimg=="],
"opencontrol/@modelcontextprotocol/sdk/express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="],
"opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="],
@@ -4875,22 +4890,6 @@
"opencontrol/@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.24.5", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.60", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N4feqnOBDA4O4yocpat5vOiV06HqJVwJGx8rEZE9DiOtl1i+1cPQ1Lx6+zWdLhbrVBJ0ENhb7Azox8sXkm/+5Q=="],
"opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.60", "", { "os": "darwin", "cpu": "x64" }, "sha512-+z3q4WaoIs7ANU8+eTFlvnfCjAS81rk81TOdZm4TJ53Ti3/B+yheWtnV/mLpLLhvZDz2VUVxxRmfDrGMnJb4fQ=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.60", "", { "os": "linux", "cpu": "arm64" }, "sha512-/Q65sjqVGB9ygJ6lStI8n1X6RyfmJZC8XofRGEuFiMLiWcWC/xoBtztdL8LAIvHQy42y2+pl9zIiW0fWSQ0wjw=="],
"opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.60", "", { "os": "linux", "cpu": "x64" }, "sha512-AegF+g7OguIpjZKN+PS55sc3ZFY6fj+fLwfETbSRGw6NqX+aiwpae0Y3gXX1s298Yq5yQEzMXnARTCJTGH4uzg=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.60", "", { "os": "win32", "cpu": "arm64" }, "sha512-fbkq8MOZJgT3r9q3JWqsfVxRpQ1SlbmhmvB35BzukXnZBK8eA178wbSadGH6irMDrkSIYye9WYddHI/iXjmgVQ=="],
"opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.60", "", { "os": "win32", "cpu": "x64" }, "sha512-OebCL7f9+CKodBw0G+NvKIcc74bl6/sBEHfb73cACdJDJKh+T3C3Vt9H3kQQ0m1C8wRAqX6rh706OArk1pUb2A=="],
"opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"opentui-spinner/@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.9", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.1" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.8" }, "optionalPeers": ["solid-js"] }, "sha512-pCnxWrciluXCeli/dj5PIEHgbNzim3evtTn12snjqqg8QZWJNMjH1AWIp4iG/tbVjqQ72aBEymMSagvmgxubXw=="],
"parse-bmfont-xml/xml2js/sax": ["sax@1.4.3", "", {}, "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ=="],
"pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="],
@@ -5021,6 +5020,10 @@
"@modelcontextprotocol/sdk/raw-body/http-errors/statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"@opencode-ai/function/@octokit/rest/@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@15.0.2", "", { "dependencies": { "@octokit/openapi-types": "^26.0.0" } }, "sha512-rR+5VRjhYSer7sC51krfCctQhVTmjyUMAaShfPB8mscVa8tSoLyon3coxQmXu0ahJoLVWl8dSGD/3OGZlFV44Q=="],
"@opencode-ai/function/@octokit/rest/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@15.0.2", "", { "dependencies": { "@octokit/openapi-types": "^26.0.0" } }, "sha512-rR+5VRjhYSer7sC51krfCctQhVTmjyUMAaShfPB8mscVa8tSoLyon3coxQmXu0ahJoLVWl8dSGD/3OGZlFV44Q=="],
"@slack/web-api/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
@@ -5041,6 +5044,10 @@
"js-beautify/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"opencode/@octokit/rest/@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@15.0.2", "", { "dependencies": { "@octokit/openapi-types": "^26.0.0" } }, "sha512-rR+5VRjhYSer7sC51krfCctQhVTmjyUMAaShfPB8mscVa8tSoLyon3coxQmXu0ahJoLVWl8dSGD/3OGZlFV44Q=="],
"opencode/@octokit/rest/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@15.0.2", "", { "dependencies": { "@octokit/openapi-types": "^26.0.0" } }, "sha512-rR+5VRjhYSer7sC51krfCctQhVTmjyUMAaShfPB8mscVa8tSoLyon3coxQmXu0ahJoLVWl8dSGD/3OGZlFV44Q=="],
"opencontrol/@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"opencontrol/@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="],
@@ -5069,8 +5076,6 @@
"opencontrol/@modelcontextprotocol/sdk/raw-body/http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"opentui-spinner/@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"pkg-up/find-up/locate-path/p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
"pkg-up/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="],
@@ -5135,10 +5140,18 @@
"@jsx-email/cli/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"@opencode-ai/function/@octokit/rest/@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@26.0.0", "", {}, "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA=="],
"@opencode-ai/function/@octokit/rest/@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@26.0.0", "", {}, "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA=="],
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex": ["regex@5.1.1", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-dN5I359AVGPnwzJm2jN1k0W9LPZ+ePvoOeVMMfqIMFz53sSwXkxaJoxr50ptnsC771lK95BnTrVSZxq0b9yCGw=="],
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es/regex-recursion": ["regex-recursion@5.1.1", "", { "dependencies": { "regex": "^5.1.1", "regex-utilities": "^2.3.0" } }, "sha512-ae7SBCbzVNrIjgSbh7wMznPcQel1DNlDtzensnFxpiNpXt1U2ju/bHugH422r+4LAVS1FpW1YCwilmnNsjum9w=="],
"opencode/@octokit/rest/@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@26.0.0", "", {}, "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA=="],
"opencode/@octokit/rest/@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@26.0.0", "", {}, "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA=="],
"opencontrol/@modelcontextprotocol/sdk/express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"opencontrol/@modelcontextprotocol/sdk/express/body-parser/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1765425892,
"narHash": "sha256-jlQpSkg2sK6IJVzTQBDyRxQZgKADC2HKMRfGCSgNMHo=",
"lastModified": 1765934234,
"narHash": "sha256-pJjWUzNnjbIAMIc5gRFUuKCDQ9S1cuh3b2hKgA7Mc4A=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "5d6bdbddb4695a62f0d00a3620b37a15275a5093",
"rev": "af84f9d270d404c17699522fab95bbf928a2d92f",
"type": "github"
},
"original": {
+11
View File
@@ -17,6 +17,15 @@ inputs:
description: "Custom prompt to override the default prompt"
required: false
use_github_token:
description: "Use GITHUB_TOKEN directly instead of OpenCode App token exchange. When true, skips OIDC and uses the GITHUB_TOKEN env var."
required: false
default: "false"
mentions:
description: "Comma-separated list of trigger phrases (case-insensitive). Defaults to '/opencode,/oc'"
required: false
runs:
using: "composite"
steps:
@@ -51,3 +60,5 @@ runs:
MODEL: ${{ inputs.model }}
SHARE: ${{ inputs.share }}
PROMPT: ${{ inputs.prompt }}
USE_GITHUB_TOKEN: ${{ inputs.use_github_token }}
MENTIONS: ${{ inputs.mentions }}
+1 -1
View File
@@ -13,7 +13,7 @@
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@octokit/graphql": "9.0.1",
"@octokit/rest": "22.0.0",
"@octokit/rest": "catalog:",
"@opencode-ai/sdk": "workspace:*"
}
}
+9 -8
View File
@@ -240,22 +240,23 @@ download_with_progress() {
download_and_install() {
print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}version: ${NC}$specific_version"
mkdir -p opencodetmp && cd opencodetmp
local tmp_dir="${TMPDIR:-/tmp}/opencode_install_$$"
mkdir -p "$tmp_dir"
if [[ "$os" == "windows" ]] || ! download_with_progress "$url" "$filename"; then
# Fallback to standard curl on Windows or if custom progress fails
curl -# -L -o "$filename" "$url"
if [[ "$os" == "windows" ]] || ! [ -t 2 ] || ! download_with_progress "$url" "$tmp_dir/$filename"; then
# Fallback to standard curl on Windows, non-TTY environments, or if custom progress fails
curl -# -L -o "$tmp_dir/$filename" "$url"
fi
if [ "$os" = "linux" ]; then
tar -xzf "$filename"
tar -xzf "$tmp_dir/$filename" -C "$tmp_dir"
else
unzip -q "$filename"
unzip -q "$tmp_dir/$filename" -d "$tmp_dir"
fi
mv opencode "$INSTALL_DIR"
mv "$tmp_dir/opencode" "$INSTALL_DIR"
chmod 755 "${INSTALL_DIR}/opencode"
cd .. && rm -rf opencodetmp
rm -rf "$tmp_dir"
}
check_version
+1 -1
View File
@@ -1,3 +1,3 @@
{
"nodeModules": "sha256-nWSAnQEm/t1ESZe23dr4JnIOJQ0JLN0w4NVoMJajbVQ="
"nodeModules": "sha256-g6XHWk9IoDoeXbvENs+U2fqk185xKMLb0BRopCbXaIk="
}
+6 -3
View File
@@ -4,7 +4,7 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.3",
"packageManager": "bun@1.3.4",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"typecheck": "bun turbo typecheck",
@@ -20,7 +20,8 @@
"packages/slack"
],
"catalog": {
"@types/bun": "1.3.3",
"@types/bun": "1.3.4",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
@@ -30,7 +31,8 @@
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/precision-diffs": "0.6.1",
"@pierre/diffs": "1.0.0-beta.3",
"@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"ai": "5.0.97",
@@ -62,6 +64,7 @@
},
"dependencies": {
"@aws-sdk/client-s3": "3.933.0",
"@octokit/rest": "22.0.1",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"typescript": "catalog:"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.0.152",
"version": "1.0.165",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+21 -2
View File
@@ -119,8 +119,8 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
<section data-component="top">
<div onContextMenu={handleLogoContextMenu}>
<A href="/">
<img data-slot="logo light" src={logoLight} alt="opencode logo light" />
<img data-slot="logo dark" src={logoDark} alt="opencode logo dark" />
<img data-slot="logo light" src={logoLight} alt="opencode logo light" width="189" height="34" />
<img data-slot="logo dark" src={logoDark} alt="opencode logo dark" width="189" height="34" />
</A>
</div>
@@ -169,6 +169,25 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
</Match>
</Switch>
</li>
<Show when={!props.hideGetStarted}>
{" "}
<li>
{" "}
<A href="/download" data-slot="cta-button">
{" "}
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
{" "}
<path
d="M12.1875 9.75L9.00001 12.9375L5.8125 9.75M9.00001 2.0625L9 12.375M14.4375 15.9375H3.5625"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>{" "}
</svg>{" "}
Free{" "}
</A>{" "}
</li>
</Show>
</ul>
</nav>
<nav data-component="nav-mobile">
+2 -2
View File
@@ -22,8 +22,8 @@ export const config = {
// Static stats (used on landing page)
stats: {
contributors: "375",
commits: "5,250",
contributors: "400",
commits: "5,000",
monthlyUsers: "400,000",
},
} as const
@@ -1,6 +1,8 @@
// @refresh reload
import { createHandler, StartServer } from "@solidjs/start/server"
const criticalCSS = `[data-component="top"]{min-height:80px;display:flex;align-items:center}`
export default createHandler(
() => (
<StartServer
@@ -11,6 +13,7 @@ export default createHandler(
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
<style>{criticalCSS}</style>
{assets}
</head>
<body>
@@ -110,10 +110,13 @@
[data-slot="cta-button"] {
background: var(--color-background-strong);
color: var(--color-text-inverted);
padding: 8px 16px;
padding: 8px 16px 8px 10px;
border-radius: 4px;
font-weight: 500;
text-decoration: none;
display: flex;
align-items: center;
gap: 8px;
@media (max-width: 55rem) {
display: none;
@@ -0,0 +1,37 @@
import { APIEvent } from "@solidjs/start"
import { DownloadPlatform } from "./types"
const assetNames: Record<string, string> = {
"darwin-aarch64-dmg": "opencode-desktop-darwin-aarch64.dmg",
"darwin-x64-dmg": "opencode-desktop-darwin-x64.dmg",
"windows-x64-nsis": "opencode-desktop-windows-x64.exe",
"linux-x64-deb": "opencode-desktop-linux-amd64.deb",
"linux-x64-rpm": "opencode-desktop-linux-x86_64.rpm",
} satisfies Record<DownloadPlatform, string>
// Doing this on the server lets us preserve the original name for platforms we don't care to rename for
const downloadNames: Record<string, string> = {
"darwin-aarch64-dmg": "OpenCode Desktop.dmg",
"darwin-x64-dmg": "OpenCode Desktop.dmg",
"windows-x64-nsis": "OpenCode Desktop Installer.exe",
} satisfies { [K in DownloadPlatform]?: string }
export async function GET({ params: { platform } }: APIEvent) {
const assetName = assetNames[platform]
if (!assetName) return new Response("Not Found", { status: 404 })
const resp = await fetch(`https://github.com/sst/opencode/releases/latest/download/${assetName}`, {
cf: {
// in case gh releases has rate limits
cacheTtl: 60 * 60 * 24,
cacheEverything: true,
},
} as any)
const downloadName = downloadNames[platform]
const headers = new Headers(resp.headers)
if (downloadName) headers.set("content-disposition", `attachment; filename="${downloadName}"`)
return new Response(resp.body, { ...resp, headers })
}
@@ -8,7 +8,51 @@ import { Faq } from "~/component/faq"
import desktopAppIcon from "../../asset/lander/opencode-desktop-icon.png"
import { Legal } from "~/component/legal"
import { config } from "~/config"
import { github } from "~/lib/github"
import { createSignal, onMount, Show, JSX } from "solid-js"
import { DownloadPlatform } from "./types"
type OS = "macOS" | "Windows" | "Linux" | null
function detectOS(): OS {
if (typeof navigator === "undefined") return null
const platform = navigator.platform.toLowerCase()
const userAgent = navigator.userAgent.toLowerCase()
if (platform.includes("mac") || userAgent.includes("mac")) return "macOS"
if (platform.includes("win") || userAgent.includes("win")) return "Windows"
if (platform.includes("linux") || userAgent.includes("linux")) return "Linux"
return null
}
function getDownloadPlatform(os: OS): DownloadPlatform {
switch (os) {
case "macOS":
return "darwin-aarch64-dmg"
case "Windows":
return "windows-x64-nsis"
case "Linux":
return "linux-x64-deb"
default:
return "darwin-aarch64-dmg"
}
}
function getDownloadHref(platform: DownloadPlatform) {
return `/download/${platform}`
}
function IconDownload(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
return (
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M13.9583 10.6247L10 14.583L6.04167 10.6247M10 2.08301V13.958M16.25 17.9163H3.75"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg>
)
}
function CopyStatus() {
return (
@@ -20,14 +64,12 @@ function CopyStatus() {
}
export default function Download() {
const githubData = createAsync(() => github(), {
deferStream: true,
const [detectedOS, setDetectedOS] = createSignal<OS>(null)
onMount(() => {
setDetectedOS(detectOS())
})
const download = () => {
const version = githubData()?.release.tag_name
if (!version) return null
return `https://github.com/sst/opencode/releases/download/${version}`
}
const handleCopyClick = (command: string) => (event: Event) => {
const button = event.currentTarget as HTMLButtonElement
navigator.clipboard.writeText(command)
@@ -52,6 +94,12 @@ export default function Download() {
<div data-component="hero-text">
<h1>Download OpenCode</h1>
<p>Available in Beta for macOS, Windows, and Linux</p>
<Show when={detectedOS()}>
<a href={getDownloadHref(getDownloadPlatform(detectedOS()))} data-component="download-button">
<IconDownload />
Download for {detectedOS()}
</a>
</Show>
</div>
</section>
@@ -101,6 +149,12 @@ export default function Download() {
<span>[2]</span> OpenCode Desktop (Beta)
</div>
<div data-component="section-content">
<button data-component="cli-row" onClick={handleCopyClick("brew install --cask opencode-desktop")}>
<code>
brew install --cask <strong>opencode-desktop</strong>
</code>
<CopyStatus />
</button>
<div data-component="download-row">
<div data-component="download-info">
<span data-slot="icon">
@@ -115,7 +169,7 @@ export default function Download() {
macOS (<span data-slot="hide-narrow">Apple </span>Silicon)
</span>
</div>
<a href={download() + "/opencode-desktop-darwin-aarch64.dmg"} data-component="action-button">
<a href={getDownloadHref("darwin-aarch64-dmg")} data-component="action-button">
Download
</a>
</div>
@@ -131,7 +185,7 @@ export default function Download() {
</span>
<span>macOS (Intel)</span>
</div>
<a href={download() + "/opencode-desktop-darwin-x64.dmg"} data-component="action-button">
<a href={getDownloadHref("darwin-x64-dmg")} data-component="action-button">
Download
</a>
</div>
@@ -154,7 +208,7 @@ export default function Download() {
</span>
<span>Windows (x64)</span>
</div>
<a href={download() + "/opencode-desktop-windows-x64.exe"} data-component="action-button">
<a href={getDownloadHref("windows-x64-nsis")} data-component="action-button">
Download
</a>
</div>
@@ -170,7 +224,7 @@ export default function Download() {
</span>
<span>Linux (.deb)</span>
</div>
<a href={download() + "/opencode-desktop-linux-amd64.deb"} data-component="action-button">
<a href={getDownloadHref("linux-x64-deb")} data-component="action-button">
Download
</a>
</div>
@@ -186,7 +240,7 @@ export default function Download() {
</span>
<span>Linux (.rpm)</span>
</div>
<a href={download() + "/opencode-desktop-linux-x86_64.rpm"} data-component="action-button">
<a href={getDownloadHref("linux-x64-rpm")} data-component="action-button">
Download
</a>
</div>
@@ -0,0 +1 @@
export type DownloadPlatform = `darwin-${"x64" | "aarch64"}-dmg` | "windows-x64-nsis" | `linux-x64-${"deb" | "rpm"}`
@@ -110,10 +110,13 @@
[data-slot="cta-button"] {
background: var(--color-background-strong);
color: var(--color-text-inverted);
padding: 8px 16px;
padding: 8px 16px 8px 10px;
border-radius: 4px;
font-weight: 500;
text-decoration: none;
display: flex;
align-items: center;
gap: 8px;
@media (max-width: 55rem) {
display: none;
@@ -206,6 +206,7 @@ body {
[data-component="top"] {
padding: 24px var(--padding);
height: 80px;
min-height: 80px;
position: sticky;
top: 0;
display: flex;
+16 -1
View File
@@ -52,6 +52,21 @@ export default function Home() {
<div data-component="content">
<section data-component="hero">
<div data-component="desktop-app-banner">
<span data-slot="badge">New</span>
<div data-slot="content">
<span data-slot="text">
Desktop app available in beta<span data-slot="platforms"> on macOS, Windows, and Linux</span>.
</span>
<a href="/download" data-slot="link">
Download now
</a>
<a href="/download" data-slot="link-mobile">
Download the desktop beta now
</a>
</div>
</div>
<div data-slot="hero-copy">
{/*<a data-slot="releases"*/}
{/* href={release()?.url ?? `${config.github.repoUrl}/releases`}*/}
@@ -213,7 +228,7 @@ export default function Home() {
<span>[*]</span>
<p>
With over <strong>{config.github.starsFormatted.full}</strong> GitHub stars,{" "}
<strong>{config.stats.contributors}</strong> contributors, and almost{" "}
<strong>{config.stats.contributors}</strong> contributors, and over{" "}
<strong>{config.stats.commits}</strong> commits, OpenCode is used and trusted by over{" "}
<strong>{config.stats.monthlyUsers}</strong> developers every month.
</p>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.152",
"version": "1.0.165",
"private": true,
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.152",
"version": "1.0.165",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.152",
"version": "1.0.165",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
"version": "1.0.152",
"version": "1.0.165",
"description": "",
"type": "module",
"exports": {
@@ -37,9 +37,10 @@
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "4.3.3",
"@solid-primitives/storage": "catalog:",
"@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
+53 -36
View File
@@ -1,20 +1,25 @@
import "@/index.css"
import { Show } from "solid-js"
import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { Diff } from "@opencode-ai/ui/diff"
import { GlobalSyncProvider } from "./context/global-sync"
import { Code } from "@opencode-ai/ui/code"
import { GlobalSyncProvider } from "@/context/global-sync"
import { LayoutProvider } from "@/context/layout"
import { GlobalSDKProvider } from "@/context/global-sdk"
import { TerminalProvider } from "@/context/terminal"
import { PromptProvider } from "@/context/prompt"
import { NotificationProvider } from "@/context/notification"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command"
import Layout from "@/pages/layout"
import Home from "@/pages/home"
import DirectoryLayout from "@/pages/directory-layout"
import Session from "@/pages/session"
import { LayoutProvider } from "./context/layout"
import { GlobalSDKProvider } from "./context/global-sdk"
import { SessionProvider } from "./context/session"
import { Show } from "solid-js"
import { NotificationProvider } from "./context/notification"
declare global {
interface Window {
@@ -33,36 +38,48 @@ const url =
export function App() {
return (
<MarkedProvider>
<DiffComponentProvider component={Diff}>
<GlobalSDKProvider url={url}>
<GlobalSyncProvider>
<LayoutProvider>
<NotificationProvider>
<MetaProvider>
<Font />
<Router root={Layout}>
<Route path="/" component={Home} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id || true} keyed>
<SessionProvider>
<Session />
</SessionProvider>
</Show>
<MetaProvider>
<Font />
<DialogProvider>
<MarkedProvider>
<DiffComponentProvider component={Diff}>
<CodeComponentProvider component={Code}>
<GlobalSDKProvider url={url}>
<GlobalSyncProvider>
<LayoutProvider>
<NotificationProvider>
<Router
root={(props) => (
<CommandProvider>
<Layout>{props.children}</Layout>
</CommandProvider>
)}
/>
</Route>
</Router>
</MetaProvider>
</NotificationProvider>
</LayoutProvider>
</GlobalSyncProvider>
</GlobalSDKProvider>
</DiffComponentProvider>
</MarkedProvider>
>
<Route path="/" component={Home} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id || true} keyed>
<TerminalProvider>
<PromptProvider>
<Session />
</PromptProvider>
</TerminalProvider>
</Show>
)}
/>
</Route>
</Router>
</NotificationProvider>
</LayoutProvider>
</GlobalSyncProvider>
</GlobalSDKProvider>
</CodeComponentProvider>
</DiffComponentProvider>
</MarkedProvider>
</DialogProvider>
</MetaProvider>
)
}
@@ -0,0 +1,381 @@
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useGlobalSync } from "@/context/global-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { usePlatform } from "@/context/platform"
import { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List, ListRef } from "@opencode-ai/ui/list"
import { Button } from "@opencode-ai/ui/button"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TextField } from "@opencode-ai/ui/text-field"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { IconName } from "@opencode-ai/ui/icons/provider"
import { iife } from "@opencode-ai/util/iife"
import { Link } from "@/components/link"
import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogSelectModel } from "./dialog-select-model"
export function DialogConnectProvider(props: { provider: string }) {
const dialog = useDialog()
const globalSync = useGlobalSync()
const globalSDK = useGlobalSDK()
const platform = usePlatform()
const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
const methods = createMemo(
() =>
globalSync.data.provider_auth[props.provider] ?? [
{
type: "api",
label: "API key",
},
],
)
const [store, setStore] = createStore({
methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization,
state: "pending" as undefined | "pending" | "complete" | "error",
error: undefined as string | undefined,
})
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
async function selectMethod(index: number) {
const method = methods()[index]
setStore(
produce((draft) => {
draft.methodIndex = index
draft.authorization = undefined
draft.state = undefined
draft.error = undefined
}),
)
if (method.type === "oauth") {
setStore("state", "pending")
const start = Date.now()
await globalSDK.client.provider.oauth
.authorize(
{
providerID: props.provider,
method: index,
},
{ throwOnError: true },
)
.then((x) => {
const elapsed = Date.now() - start
const delay = 1000 - elapsed
if (delay > 0) {
setTimeout(() => {
setStore("state", "complete")
setStore("authorization", x.data!)
}, delay)
return
}
setStore("state", "complete")
setStore("authorization", x.data!)
})
.catch((e) => {
setStore("state", "error")
setStore("error", String(e))
})
}
}
let listRef: ListRef | undefined
function handleKey(e: KeyboardEvent) {
if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
return
}
if (e.key === "Escape") return
listRef?.onKeyDown(e)
}
onMount(() => {
if (methods().length === 1) {
selectMethod(0)
}
document.addEventListener("keydown", handleKey)
onCleanup(() => {
document.removeEventListener("keydown", handleKey)
})
})
async function complete() {
await globalSDK.client.global.dispose()
dialog.close()
showToast({
variant: "success",
icon: "circle-check",
title: `${provider().name} connected`,
description: `${provider().name} models are now available to use.`,
})
}
function goBack() {
if (methods().length === 1) {
dialog.show(() => <DialogSelectProvider />)
return
}
if (store.authorization) {
setStore("authorization", undefined)
setStore("methodIndex", undefined)
return
}
if (store.methodIndex) {
setStore("methodIndex", undefined)
return
}
dialog.show(() => <DialogSelectProvider />)
}
return (
<Dialog title={<IconButton tabIndex={-1} icon="arrow-left" variant="ghost" onClick={goBack} />}>
<div class="flex flex-col gap-6 px-2.5 pb-3">
<div class="px-2.5 flex gap-4 items-center">
<ProviderIcon id={props.provider as IconName} class="size-5 shrink-0 icon-strong-base" />
<div class="text-16-medium text-text-strong">
<Switch>
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
Login with Claude Pro/Max
</Match>
<Match when={true}>Connect {provider().name}</Match>
</Switch>
</div>
</div>
<div class="px-2.5 pb-10 flex flex-col gap-6">
<Switch>
<Match when={store.methodIndex === undefined}>
<div class="text-14-regular text-text-base">Select login method for {provider().name}.</div>
<div class="">
<List
ref={(ref) => (listRef = ref)}
items={methods}
key={(m) => m?.label}
onSelect={async (method, index) => {
if (!method) return
selectMethod(index)
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-4">
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
<div class="w-2.5 h-0.5 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
</div>
<span>{i.label}</span>
</div>
)}
</List>
</div>
</Match>
<Match when={store.state === "pending"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-4">
<Spinner />
<span>Authorization in progress...</span>
</div>
</div>
</Match>
<Match when={store.state === "error"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-4">
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
<span>Authorization failed: {store.error}</span>
</div>
</div>
</Match>
<Match when={method()?.type === "api"}>
{iife(() => {
const [formStore, setFormStore] = createStore({
value: "",
error: undefined as string | undefined,
})
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const form = e.currentTarget as HTMLFormElement
const formData = new FormData(form)
const apiKey = formData.get("apiKey") as string
if (!apiKey?.trim()) {
setFormStore("error", "API key is required")
return
}
setFormStore("error", undefined)
await globalSDK.client.auth.set({
providerID: props.provider,
auth: {
type: "api",
key: apiKey,
},
})
await complete()
}
return (
<div class="flex flex-col gap-6">
<Switch>
<Match when={provider().id === "opencode"}>
<div class="flex flex-col gap-4">
<div class="text-14-regular text-text-base">
OpenCode Zen gives you access to a curated set of reliable optimized models for coding
agents.
</div>
<div class="text-14-regular text-text-base">
With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and more.
</div>
<div class="text-14-regular text-text-base">
Visit{" "}
<Link href="https://opencode.ai/zen" tabIndex={-1}>
opencode.ai/zen
</Link>{" "}
to collect your API key.
</div>
</div>
</Match>
<Match when={true}>
<div class="text-14-regular text-text-base">
Enter your {provider().name} API key to connect your account and use {provider().name} models
in OpenCode.
</div>
</Match>
</Switch>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<TextField
autofocus
type="text"
label={`${provider().name} API key`}
placeholder="API key"
name="apiKey"
value={formStore.value}
onChange={setFormStore.bind(null, "value")}
validationState={formStore.error ? "invalid" : undefined}
error={formStore.error}
/>
<Button class="w-auto" type="submit" size="large" variant="primary">
Submit
</Button>
</form>
</div>
)
})}
</Match>
<Match when={method()?.type === "oauth"}>
<Switch>
<Match when={store.authorization?.method === "code"}>
{iife(() => {
const [formStore, setFormStore] = createStore({
value: "",
error: undefined as string | undefined,
})
onMount(() => {
if (store.authorization?.method === "code" && store.authorization?.url) {
platform.openLink(store.authorization.url)
}
})
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const form = e.currentTarget as HTMLFormElement
const formData = new FormData(form)
const code = formData.get("code") as string
if (!code?.trim()) {
setFormStore("error", "Authorization code is required")
return
}
setFormStore("error", undefined)
const { error } = await globalSDK.client.provider.oauth.callback({
providerID: props.provider,
method: store.methodIndex,
code,
})
if (!error) {
await complete()
return
}
setFormStore("error", "Invalid authorization code")
}
return (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
Visit <Link href={store.authorization!.url}>this link</Link> to collect your authorization
code to connect your account and use {provider().name} models in OpenCode.
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<TextField
autofocus
type="text"
label={`${method()?.label} authorization code`}
placeholder="Authorization code"
name="code"
value={formStore.value}
onChange={setFormStore.bind(null, "value")}
validationState={formStore.error ? "invalid" : undefined}
error={formStore.error}
/>
<Button class="w-auto" type="submit" size="large" variant="primary">
Submit
</Button>
</form>
</div>
)
})}
</Match>
<Match when={store.authorization?.method === "auto"}>
{iife(() => {
const code = createMemo(() => {
const instructions = store.authorization?.instructions
if (instructions?.includes(":")) {
return instructions?.split(":")[1]?.trim()
}
return instructions
})
onMount(async () => {
const result = await globalSDK.client.provider.oauth.callback({
providerID: props.provider,
method: store.methodIndex,
})
if (result.error) {
// TODO: show error
dialog.close()
return
}
await complete()
})
return (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
Visit <Link href={store.authorization!.url}>this link</Link> and enter the code below to
connect your account and use {provider().name} models in OpenCode.
</div>
<TextField label="Confirmation code" class="font-mono" value={code()} readOnly copyable />
<div class="text-14-regular text-text-base flex items-center gap-4">
<Spinner />
<span>Waiting for authorization...</span>
</div>
</div>
)
})}
</Match>
</Switch>
</Match>
</Switch>
</div>
</div>
</Dialog>
)
}
@@ -0,0 +1,50 @@
import { Component } from "solid-js"
import { useLocal } from "@/context/local"
import { popularProviders } from "@/hooks/use-providers"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch"
export const DialogManageModels: Component = () => {
const local = useLocal()
return (
<Dialog title="Manage models" description="Customize which models appear in the model selector.">
<List
class="px-2.5"
search={{ placeholder: "Search models", autofocus: true }}
emptyMessage="No model results"
key={(x) => `${x?.provider?.id}:${x?.id}`}
items={local.model.list()}
filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.name}
sortGroupsBy={(a, b) => {
const aProvider = a.items[0].provider.id
const bProvider = b.items[0].provider.id
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
}}
onSelect={(x) => {
if (!x) return
const visible = local.model.visible({ modelID: x.id, providerID: x.provider.id })
local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible)
}}
>
{(i) => (
<div class="w-full flex items-center justify-between gap-x-2.5">
<span>{i.name}</span>
<div onClick={(e) => e.stopPropagation()}>
<Switch
checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })}
onChange={(checked) => {
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
}}
/>
</div>
</div>
)}
</List>
</Dialog>
)
}
@@ -0,0 +1,49 @@
import { useLocal } from "@/context/local"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useLayout } from "@/context/layout"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useParams } from "@solidjs/router"
import { createMemo } from "solid-js"
export function DialogSelectFile() {
const layout = useLayout()
const local = useLocal()
const dialog = useDialog()
const params = useParams()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
return (
<Dialog title="Select file">
<List
class="px-2.5"
search={{ placeholder: "Search files", autofocus: true }}
emptyMessage="No files found"
items={local.file.searchFiles}
key={(x) => x}
onSelect={(path) => {
if (path) {
tabs().open("file://" + path)
}
dialog.close()
}}
>
{(i) => (
<div class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-2 grow min-w-0">
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(i)}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
</div>
</div>
</div>
)}
</List>
</Dialog>
)
}
@@ -0,0 +1,119 @@
import { Component, onCleanup, onMount, Show } from "solid-js"
import { useLocal } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { Button } from "@opencode-ai/ui/button"
import { Tag } from "@opencode-ai/ui/tag"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List, ListRef } from "@opencode-ai/ui/list"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { IconName } from "@opencode-ai/ui/icons/provider"
import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogConnectProvider } from "./dialog-connect-provider"
export const DialogSelectModelUnpaid: Component = () => {
const local = useLocal()
const dialog = useDialog()
const providers = useProviders()
let listRef: ListRef | undefined
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") return
listRef?.onKeyDown(e)
}
onMount(() => {
document.addEventListener("keydown", handleKey)
onCleanup(() => {
document.removeEventListener("keydown", handleKey)
})
})
return (
<Dialog title="Select model">
<div class="flex flex-col gap-3 px-2.5">
<div class="text-14-medium text-text-base px-2.5">Free models provided by OpenCode</div>
<List
ref={(ref) => (listRef = ref)}
items={local.model.list}
current={local.model.current()}
key={(x) => `${x.provider.id}:${x.id}`}
onSelect={(x) => {
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true,
})
dialog.close()
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-2.5">
<span>{i.name}</span>
<Tag>Free</Tag>
<Show when={i.latest}>
<Tag>Latest</Tag>
</Show>
</div>
)}
</List>
<div />
<div />
</div>
<div class="px-1.5 pb-1.5">
<div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base">
<div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-4">
<div class="px-2 text-14-medium text-text-base">Add more models from popular providers</div>
<div class="w-full">
<List
class="w-full"
key={(x) => x?.id}
items={providers.popular}
activeIcon="plus-small"
sortBy={(a, b) => {
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
return a.name.localeCompare(b.name)
}}
onSelect={(x) => {
if (!x) return
dialog.show(() => <DialogConnectProvider provider={x.id} />)
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-4">
<ProviderIcon
data-slot="list-item-extra-icon"
id={i.id as IconName}
// TODO: clean this up after we update icon in models.dev
classList={{
"text-icon-weak-base": true,
"size-4 mx-0.5": i.id === "opencode",
"size-5": i.id !== "opencode",
}}
/>
<span>{i.name}</span>
<Show when={i.id === "opencode"}>
<Tag>Recommended</Tag>
</Show>
<Show when={i.id === "anthropic"}>
<div class="text-14-regular text-text-weak">Connect with Claude Pro/Max or API key</div>
</Show>
</div>
)}
</List>
<Button
variant="ghost"
class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
icon="dot-grid"
onClick={() => {
dialog.show(() => <DialogSelectProvider />)
}}
>
View all providers
</Button>
</div>
</div>
</div>
</div>
</Dialog>
)
}
@@ -0,0 +1,84 @@
import { Component, createMemo, Show } from "solid-js"
import { useLocal } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { popularProviders } from "@/hooks/use-providers"
import { Button } from "@opencode-ai/ui/button"
import { Tag } from "@opencode-ai/ui/tag"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogManageModels } from "./dialog-manage-models"
export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
const local = useLocal()
const dialog = useDialog()
const models = createMemo(() =>
local.model
.list()
.filter((m) => local.model.visible({ modelID: m.id, providerID: m.provider.id }))
.filter((m) => (props.provider ? m.provider.id === props.provider : true)),
)
return (
<Dialog
title="Select model"
action={
<Button
class="h-7 -my-1 text-14-medium"
icon="plus-small"
tabIndex={-1}
onClick={() => dialog.show(() => <DialogSelectProvider />)}
>
Connect provider
</Button>
}
>
<List
class="px-2.5"
search={{ placeholder: "Search models", autofocus: true }}
emptyMessage="No model results"
key={(x) => `${x.provider.id}:${x.id}`}
items={models}
current={local.model.current()}
filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.name}
sortGroupsBy={(a, b) => {
if (a.category === "Recent" && b.category !== "Recent") return -1
if (b.category === "Recent" && a.category !== "Recent") return 1
const aProvider = a.items[0].provider.id
const bProvider = b.items[0].provider.id
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
}}
onSelect={(x) => {
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true,
})
dialog.close()
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-2.5">
<span>{i.name}</span>
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
<Tag>Free</Tag>
</Show>
<Show when={i.latest}>
<Tag>Latest</Tag>
</Show>
</div>
)}
</List>
<Button
variant="ghost"
class="ml-3 mt-5 mb-6 text-text-base self-start"
onClick={() => dialog.show(() => <DialogManageModels />)}
>
Manage models
</Button>
</Dialog>
)
}
@@ -0,0 +1,64 @@
import { Component, Show } from "solid-js"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Tag } from "@opencode-ai/ui/tag"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { IconName } from "@opencode-ai/ui/icons/provider"
import { DialogConnectProvider } from "./dialog-connect-provider"
export const DialogSelectProvider: Component = () => {
const dialog = useDialog()
const providers = useProviders()
return (
<Dialog title="Connect provider">
<List
class="px-2.5"
search={{ placeholder: "Search providers", autofocus: true }}
activeIcon="plus-small"
key={(x) => x?.id}
items={providers.all}
filterKeys={["id", "name"]}
groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")}
sortBy={(a, b) => {
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
return a.name.localeCompare(b.name)
}}
sortGroupsBy={(a, b) => {
if (a.category === "Popular" && b.category !== "Popular") return -1
if (b.category === "Popular" && a.category !== "Popular") return 1
return 0
}}
onSelect={(x) => {
if (!x) return
dialog.show(() => <DialogConnectProvider provider={x.id} />)
}}
>
{(i) => (
<div class="px-1.25 w-full flex items-center gap-x-4">
<ProviderIcon
data-slot="list-item-extra-icon"
id={i.id as IconName}
// TODO: clean this up after we update icon in models.dev
classList={{
"text-icon-weak-base": true,
"size-4 mx-0.5": i.id === "opencode",
"size-5": i.id !== "opencode",
}}
/>
<span>{i.name}</span>
<Show when={i.id === "opencode"}>
<Tag>Recommended</Tag>
</Show>
<Show when={i.id === "anthropic"}>
<div class="text-14-regular text-text-weak">Connect with Claude Pro/Max or API key</div>
</Show>
</div>
)}
</List>
</Dialog>
)
}
+35 -1
View File
@@ -1,27 +1,34 @@
import { useGlobalSync } from "@/context/global-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { useLayout } from "@/context/layout"
import { Session } from "@opencode-ai/sdk/v2/client"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Mark } from "@opencode-ai/ui/logo"
import { Popover } from "@opencode-ai/ui/popover"
import { Select } from "@opencode-ai/ui/select"
import { TextField } from "@opencode-ai/ui/text-field"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { base64Decode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path"
import { A, useParams } from "@solidjs/router"
import { createMemo, Show } from "solid-js"
import { createMemo, createResource, Show } from "solid-js"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { iife } from "@opencode-ai/util/iife"
export function Header(props: {
navigateToProject: (directory: string) => void
navigateToSession: (session: Session | undefined) => void
}) {
const globalSync = useGlobalSync()
const globalSDK = useGlobalSDK()
const layout = useLayout()
const params = useParams()
const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
const store = createMemo(() => globalSync.child(currentDirectory())[0])
const sessions = createMemo(() => store().session ?? [])
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
const shareEnabled = createMemo(() => store().config.share !== "disabled")
return (
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
@@ -105,6 +112,33 @@ export function Header(props: {
</div>
</Button>
</Tooltip>
<Show when={shareEnabled() && currentSession()}>
<Popover
title="Share session"
trigger={
<Tooltip class="shrink-0" value="Share session">
<IconButton icon="share" variant="ghost" class="" />
</Tooltip>
}
>
{iife(() => {
const [url] = createResource(
() => currentSession(),
async (session) => {
if (!session) return
let shareURL = session.share?.url
if (!shareURL) {
shareURL = await globalSDK.client.session
.share({ sessionID: session.id, directory: currentDirectory() })
.then((r) => r.data?.share?.url)
}
return shareURL
},
)
return <Show when={url()}>{(url) => <TextField value={url()} readOnly copyable class="w-72" />}</Show>
})}
</Popover>
</Show>
</div>
</Show>
</div>
File diff suppressed because it is too large Load Diff
+15 -6
View File
@@ -2,7 +2,8 @@ import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js"
import { useSDK } from "@/context/sdk"
import { SerializeAddon } from "@/addons/serialize"
import { LocalPTY } from "@/context/session"
import { LocalPTY } from "@/context/terminal"
import { usePrefersDark } from "@solid-primitives/media"
export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY
@@ -21,6 +22,7 @@ export const Terminal = (props: TerminalProps) => {
let serializeAddon: SerializeAddon
let fitAddon: FitAddon
let handleResize: () => void
const prefersDark = usePrefersDark()
onMount(async () => {
ghostty = await Ghostty.load()
@@ -29,12 +31,19 @@ export const Terminal = (props: TerminalProps) => {
term = new Term({
cursorBlink: true,
fontSize: 14,
fontFamily: "TX-02, monospace",
fontFamily: "IBM Plex Mono, monospace",
allowTransparency: true,
theme: {
background: "#191515",
foreground: "#d4d4d4",
},
theme: prefersDark()
? {
background: "#191515",
foreground: "#d4d4d4",
cursor: "#d4d4d4",
}
: {
background: "#fcfcfc",
foreground: "#211e1e",
cursor: "#211e1e",
},
scrollback: 10_000,
ghostty,
})
+239
View File
@@ -0,0 +1,239 @@
import { createMemo, createSignal, onCleanup, onMount, Show, type Accessor } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
export type KeybindConfig = string
export interface Keybind {
key: string
ctrl: boolean
meta: boolean
shift: boolean
alt: boolean
}
export interface CommandOption {
id: string
title: string
description?: string
category?: string
keybind?: KeybindConfig
slash?: string
suggested?: boolean
disabled?: boolean
onSelect?: (source?: "palette" | "keybind" | "slash") => void
}
export function parseKeybind(config: string): Keybind[] {
if (!config || config === "none") return []
return config.split(",").map((combo) => {
const parts = combo.trim().toLowerCase().split("+")
const keybind: Keybind = {
key: "",
ctrl: false,
meta: false,
shift: false,
alt: false,
}
for (const part of parts) {
switch (part) {
case "ctrl":
case "control":
keybind.ctrl = true
break
case "meta":
case "cmd":
case "command":
keybind.meta = true
break
case "mod":
if (IS_MAC) keybind.meta = true
else keybind.ctrl = true
break
case "alt":
case "option":
keybind.alt = true
break
case "shift":
keybind.shift = true
break
default:
keybind.key = part
break
}
}
return keybind
})
}
export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
const eventKey = event.key.toLowerCase()
for (const kb of keybinds) {
const keyMatch = kb.key === eventKey
const ctrlMatch = kb.ctrl === (event.ctrlKey || false)
const metaMatch = kb.meta === (event.metaKey || false)
const shiftMatch = kb.shift === (event.shiftKey || false)
const altMatch = kb.alt === (event.altKey || false)
if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) {
return true
}
}
return false
}
export function formatKeybind(config: string): string {
if (!config || config === "none") return ""
const keybinds = parseKeybind(config)
if (keybinds.length === 0) return ""
const kb = keybinds[0]
const parts: string[] = []
if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl")
if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt")
if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift")
if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
if (kb.key) {
const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1)
parts.push(displayKey)
}
return IS_MAC ? parts.join("") : parts.join("+")
}
function DialogCommand(props: { options: CommandOption[] }) {
const dialog = useDialog()
return (
<Dialog title="Commands">
<List
class="px-2.5"
search={{ placeholder: "Search commands", autofocus: true }}
emptyMessage="No commands found"
items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
key={(x) => x?.id}
filterKeys={["title", "description", "category"]}
groupBy={(x) => x.category ?? ""}
onSelect={(option) => {
if (option) {
dialog.close()
option.onSelect?.("palette")
}
}}
>
{(option) => (
<div class="w-full flex items-center justify-between gap-4">
<div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong whitespace-nowrap">{option.title}</span>
<Show when={option.description}>
<span class="text-14-regular text-text-weak truncate">{option.description}</span>
</Show>
</div>
<Show when={option.keybind}>
<span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(option.keybind!)}</span>
</Show>
</div>
)}
</List>
</Dialog>
)
}
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
name: "Command",
init: () => {
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
const [suspendCount, setSuspendCount] = createSignal(0)
const dialog = useDialog()
const options = createMemo(() => {
const all = registrations().flatMap((x) => x())
const suggested = all.filter((x) => x.suggested && !x.disabled)
return [
...suggested.map((x) => ({
...x,
id: "suggested." + x.id,
category: "Suggested",
})),
...all,
]
})
const suspended = () => suspendCount() > 0
const showPalette = () => {
if (!dialog.active) {
dialog.show(() => <DialogCommand options={options().filter((x) => !x.disabled)} />)
}
}
const handleKeyDown = (event: KeyboardEvent) => {
if (suspended()) return
const paletteKeybinds = parseKeybind("mod+shift+p")
if (matchKeybind(paletteKeybinds, event)) {
event.preventDefault()
showPalette()
return
}
for (const option of options()) {
if (option.disabled) continue
if (!option.keybind) continue
const keybinds = parseKeybind(option.keybind)
if (matchKeybind(keybinds, event)) {
event.preventDefault()
option.onSelect?.("keybind")
return
}
}
}
onMount(() => {
document.addEventListener("keydown", handleKeyDown)
})
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
})
return {
register(cb: () => CommandOption[]) {
const results = createMemo(cb)
setRegistrations((arr) => [results, ...arr])
onCleanup(() => {
setRegistrations((arr) => arr.filter((x) => x !== results))
})
},
trigger(id: string, source?: "palette" | "keybind" | "slash") {
for (const option of options()) {
if (option.id === id || option.id === "suggested." + id) {
option.onSelect?.(source)
return
}
}
},
show: showPalette,
keybinds(enabled: boolean) {
setSuspendCount((count) => count + (enabled ? -1 : 1))
},
suspended,
get options() {
return options()
},
}
},
})
@@ -10,6 +10,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const sdk = createOpencodeClient({
baseUrl: props.url,
signal: abort.signal,
throwOnError: true,
})
const emitter = createGlobalEmitter<{
+287 -214
View File
@@ -13,17 +13,20 @@ import {
type SessionStatus,
type ProviderListResponse,
type ProviderAuthResponse,
type Command,
createOpencodeClient,
EventSessionError,
} from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSDK } from "./global-sdk"
import { onMount } from "solid-js"
import { ErrorPage } from "../pages/error"
import { createContext, useContext, onMount, type ParentProps, Switch, Match, createEffect } from "solid-js"
type State = {
ready: boolean
agent: Agent[]
command: Command[]
project: string
provider: ProviderListResponse
config: Config
@@ -49,233 +52,303 @@ type State = {
changes: File[]
}
export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimpleContext({
name: "GlobalSync",
init: () => {
const globalSDK = useGlobalSDK()
const [globalStore, setGlobalStore] = createStore<{
ready: boolean
path: Path
project: Project[]
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
children: Record<string, State>
}>({
ready: false,
path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: [],
provider: { all: [], connected: [], default: {} },
provider_auth: {},
children: {},
})
function createGlobalSync() {
const globalSDK = useGlobalSDK()
const [globalStore, setGlobalStore] = createStore<{
ready: boolean
error?: EventSessionError["properties"]["error"]
path: Path
project: Project[]
provider: ProviderListResponse
provider_auth: ProviderAuthResponse
children: Record<string, State>
}>({
ready: false,
path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: [],
provider: { all: [], connected: [], default: {} },
provider_auth: {},
children: {},
})
const children: Record<string, ReturnType<typeof createStore<State>>> = {}
function child(directory: string) {
if (!children[directory]) {
setGlobalStore("children", directory, {
project: "",
provider: { all: [], connected: [], default: {} },
config: {},
path: { state: "", config: "", worktree: "", directory: "", home: "" },
ready: false,
agent: [],
session: [],
session_status: {},
session_diff: {},
todo: {},
limit: 5,
message: {},
part: {},
node: [],
changes: [],
})
children[directory] = createStore(globalStore.children[directory])
bootstrapInstance(directory)
}
return children[directory]
const children: Record<string, ReturnType<typeof createStore<State>>> = {}
function child(directory: string) {
if (!children[directory]) {
setGlobalStore("children", directory, {
project: "",
provider: { all: [], connected: [], default: {} },
config: {},
path: { state: "", config: "", worktree: "", directory: "", home: "" },
ready: false,
agent: [],
command: [],
session: [],
session_status: {},
session_diff: {},
todo: {},
limit: 5,
message: {},
part: {},
node: [],
changes: [],
})
children[directory] = createStore(globalStore.children[directory])
bootstrapInstance(directory)
}
return children[directory]
}
async function loadSessions(directory: string) {
globalSDK.client.session.list({ directory }).then((x) => {
const sessions = (x.data ?? [])
async function loadSessions(directory: string) {
const [store, setStore] = child(directory)
globalSDK.client.session
.list({ directory })
.then((x) => {
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
const nonArchived = (x.data ?? [])
.slice()
.filter((s) => !s.time.archived)
.sort((a, b) => a.id.localeCompare(b.id))
.slice(0, 5)
const [, setStore] = child(directory)
// Include sessions up to the limit, plus any updated in the last hour
const sessions = nonArchived.filter((s, i) => {
if (i < store.limit) return true
const updated = new Date(s.time.updated).getTime()
return updated > fourHoursAgo
})
setStore("session", sessions)
})
}
async function bootstrapInstance(directory: string) {
const [, setStore] = child(directory)
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
directory,
.catch((err) => {
console.error("Failed to load sessions", err)
setGlobalStore("error", err)
})
const load = {
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)),
path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
session: () => loadSessions(directory),
status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)),
node: () => sdk.file.list({ path: "/" }).then((x) => setStore("node", x.data!)),
}
await Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
}
}
globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
if (directory === "global") {
switch (event.type) {
case "global.disposed": {
bootstrap()
break
}
case "project.updated": {
const result = Binary.search(globalStore.project, event.properties.id, (s) => s.id)
if (result.found) {
setGlobalStore("project", result.index, reconcile(event.properties))
return
}
setGlobalStore(
"project",
produce((draft) => {
draft.splice(result.index, 0, event.properties)
}),
)
break
}
}
return
}
const [store, setStore] = child(directory)
switch (event.type) {
case "server.instance.disposed": {
bootstrapInstance(directory)
break
}
case "session.updated": {
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
if (event.properties.info.time.archived) {
if (result.found) {
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
break
}
if (result.found) {
setStore("session", result.index, reconcile(event.properties.info))
break
}
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
}
case "session.diff":
setStore("session_diff", event.properties.sessionID, event.properties.diff)
break
case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
break
case "session.status": {
setStore("session_status", event.properties.sessionID, event.properties.status)
break
}
case "message.updated": {
const messages = store.message[event.properties.info.sessionID]
if (!messages) {
setStore("message", event.properties.info.sessionID, [event.properties.info])
break
}
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
if (result.found) {
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
break
}
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
}
case "message.part.updated": {
const part = event.properties.part
const parts = store.part[part.messageID]
if (!parts) {
setStore("part", part.messageID, [part])
break
}
const result = Binary.search(parts, part.id, (p) => p.id)
if (result.found) {
setStore("part", part.messageID, result.index, reconcile(part))
break
}
setStore(
"part",
part.messageID,
produce((draft) => {
draft.splice(result.index, 0, part)
}),
)
break
}
}
async function bootstrapInstance(directory: string) {
const [, setStore] = child(directory)
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
directory,
throwOnError: true,
})
const load = {
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)),
path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
command: () => sdk.command.list().then((x) => setStore("command", x.data ?? [])),
session: () => loadSessions(directory),
status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)),
node: () => sdk.file.list({ path: "/" }).then((x) => setStore("node", x.data!)),
}
await Promise.all(Object.values(load).map((p) => p().catch((e) => setGlobalStore("error", e))))
.then(() => setStore("ready", true))
.catch((e) => setGlobalStore("error", e))
}
async function bootstrap() {
return Promise.all([
globalSDK.client.path.get().then((x) => {
setGlobalStore("path", x.data!)
}),
globalSDK.client.project.list().then(async (x) => {
globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
if (directory === "global") {
switch (event?.type) {
case "global.disposed": {
bootstrap()
break
}
case "project.updated": {
const result = Binary.search(globalStore.project, event.properties.id, (s) => s.id)
if (result.found) {
setGlobalStore("project", result.index, reconcile(event.properties))
return
}
setGlobalStore(
"project",
x
.data!.filter((p) => !p.worktree.includes("opencode-test") && p.vcs)
.sort((a, b) => a.id.localeCompare(b.id)),
produce((draft) => {
draft.splice(result.index, 0, event.properties)
}),
)
}),
globalSDK.client.provider.list().then((x) => {
setGlobalStore("provider", x.data ?? {})
}),
globalSDK.client.provider.auth().then((x) => {
setGlobalStore("provider_auth", x.data ?? {})
}),
]).then(() => setGlobalStore("ready", true))
break
}
}
return
}
onMount(() => {
bootstrap()
})
return {
data: globalStore,
get ready() {
return globalStore.ready
},
child,
bootstrap,
project: {
loadSessions,
},
const [store, setStore] = child(directory)
switch (event.type) {
case "server.instance.disposed": {
bootstrapInstance(directory)
break
}
case "session.updated": {
const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
if (event.properties.info.time.archived) {
if (result.found) {
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
break
}
if (result.found) {
setStore("session", result.index, reconcile(event.properties.info))
break
}
setStore(
"session",
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
}
case "session.diff":
setStore("session_diff", event.properties.sessionID, event.properties.diff)
break
case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
break
case "session.status": {
setStore("session_status", event.properties.sessionID, event.properties.status)
break
}
case "message.updated": {
const messages = store.message[event.properties.info.sessionID]
if (!messages) {
setStore("message", event.properties.info.sessionID, [event.properties.info])
break
}
const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
if (result.found) {
setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
break
}
setStore(
"message",
event.properties.info.sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties.info)
}),
)
break
}
case "message.removed": {
const messages = store.message[event.properties.sessionID]
if (!messages) break
const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
if (result.found) {
setStore(
"message",
event.properties.sessionID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
break
}
case "message.part.updated": {
const part = event.properties.part
const parts = store.part[part.messageID]
if (!parts) {
setStore("part", part.messageID, [part])
break
}
const result = Binary.search(parts, part.id, (p) => p.id)
if (result.found) {
setStore("part", part.messageID, result.index, reconcile(part))
break
}
setStore(
"part",
part.messageID,
produce((draft) => {
draft.splice(result.index, 0, part)
}),
)
break
}
case "message.part.removed": {
const parts = store.part[event.properties.messageID]
if (!parts) break
const result = Binary.search(parts, event.properties.partID, (p) => p.id)
if (result.found) {
setStore(
"part",
event.properties.messageID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
}
break
}
}
},
})
})
async function bootstrap() {
return Promise.all([
globalSDK.client.path.get().then((x) => {
setGlobalStore("path", x.data!)
}),
globalSDK.client.project.list().then(async (x) => {
setGlobalStore(
"project",
x.data!.filter((p) => !p.worktree.includes("opencode-test")).sort((a, b) => a.id.localeCompare(b.id)),
)
}),
globalSDK.client.provider.list().then((x) => {
setGlobalStore("provider", x.data ?? {})
}),
globalSDK.client.provider.auth().then((x) => {
setGlobalStore("provider_auth", x.data ?? {})
}),
])
.then(() => setGlobalStore("ready", true))
.catch((e) => setGlobalStore("error", e))
}
onMount(() => {
bootstrap()
})
return {
data: globalStore,
get ready() {
return globalStore.ready
},
get error() {
return globalStore.error
},
child,
bootstrap,
project: {
loadSessions,
},
}
}
const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
export function GlobalSyncProvider(props: ParentProps) {
const value = createGlobalSync()
return (
<Switch>
<Match when={value.error}>
<ErrorPage error={value.error} />
</Match>
<Match when={value.ready}>
<GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
</Match>
</Switch>
)
}
export function useGlobalSync() {
const context = useContext(GlobalSyncContext)
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
return context
}
+87 -68
View File
@@ -1,10 +1,10 @@
import { createStore, produce } from "solid-js/store"
import { batch, createMemo, onMount } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { makePersisted } from "@solid-primitives/storage"
import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk"
import { Project } from "@opencode-ai/sdk/v2"
import { persisted } from "@/utils/persist"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
@@ -22,14 +22,18 @@ export function getAvatarColors(key?: string) {
}
}
type Dialog = "provider" | "model" | "connect"
type SessionTabs = {
active?: string
all: string[]
}
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
init: () => {
const globalSdk = useGlobalSDK()
const globalSync = useGlobalSync()
const [store, setStore] = makePersisted(
const [store, setStore, _, ready] = persisted(
"layout.v3",
createStore({
projects: [] as { worktree: string; expanded: boolean }[],
sidebar: {
@@ -43,24 +47,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
review: {
state: "pane" as "pane" | "tab",
},
sessionTabs: {} as Record<string, SessionTabs>,
}),
{
name: "layout.v1",
},
)
const [ephemeral, setEphemeral] = createStore<{
connect: {
provider?: string
state?: "pending" | "complete" | "error"
error?: string
}
dialog: {
open?: Dialog
}
}>({
connect: {},
dialog: {},
})
const usedColors = new Set<AvatarColorKey>()
function pickAvailableColor(): AvatarColorKey {
@@ -101,6 +91,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
})
return {
ready,
projects: {
list,
open(directory: string) {
@@ -169,57 +160,85 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("review", "state", "tab")
},
},
dialog: {
opened: createMemo(() => ephemeral.dialog?.open),
open(dialog: Dialog) {
batch(() => {
// if (dialog !== "connect") {
// setEphemeral("connect", {})
// }
setEphemeral("dialog", "open", dialog)
})
},
close(dialog: Dialog) {
if (ephemeral.dialog.open === dialog) {
setEphemeral(
produce((state) => {
state.dialog.open = undefined
state.connect = {}
tabs(sessionKey: string) {
const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
return {
tabs,
active: createMemo(() => tabs().active),
all: createMemo(() => tabs().all),
setActive(tab: string | undefined) {
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all: [], active: tab })
} else {
setStore("sessionTabs", sessionKey, "active", tab)
}
},
setAll(all: string[]) {
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all, active: undefined })
} else {
setStore("sessionTabs", sessionKey, "all", all)
}
},
async open(tab: string) {
if (tab === "chat") {
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all: [], active: undefined })
} else {
setStore("sessionTabs", sessionKey, "active", undefined)
}
return
}
const current = store.sessionTabs[sessionKey] ?? { all: [] }
if (tab !== "review") {
if (!current.all.includes(tab)) {
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
} else {
setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
setStore("sessionTabs", sessionKey, "active", tab)
}
return
}
}
if (!store.sessionTabs[sessionKey]) {
setStore("sessionTabs", sessionKey, { all: [], active: tab })
} else {
setStore("sessionTabs", sessionKey, "active", tab)
}
},
close(tab: string) {
const current = store.sessionTabs[sessionKey]
if (!current) return
batch(() => {
setStore(
"sessionTabs",
sessionKey,
"all",
current.all.filter((x) => x !== tab),
)
if (current.active === tab) {
const index = current.all.findIndex((f) => f === tab)
const previous = current.all[Math.max(0, index - 1)]
setStore("sessionTabs", sessionKey, "active", previous)
}
})
},
move(tab: string, to: number) {
const current = store.sessionTabs[sessionKey]
if (!current) return
const index = current.all.findIndex((f) => f === tab)
if (index === -1) return
setStore(
"sessionTabs",
sessionKey,
"all",
produce((opened) => {
opened.splice(to, 0, opened.splice(index, 1)[0])
}),
)
}
},
connect(provider: string) {
setEphemeral(
produce((state) => {
state.dialog.open = "connect"
state.connect = { provider, state: "pending" }
}),
)
},
},
connect: {
provider: createMemo(() => ephemeral.connect.provider),
state: createMemo(() => ephemeral.connect.state),
complete() {
setEphemeral(
produce((state) => {
state.dialog.open = "model"
state.connect.state = "complete"
}),
)
},
error(message: string) {
setEphemeral(
produce((state) => {
state.connect.state = "error"
state.connect.error = message
}),
)
},
clear() {
setEphemeral("connect", {})
},
},
}
},
}
},
+80 -22
View File
@@ -1,12 +1,14 @@
import { createStore, produce, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo } from "solid-js"
import { uniqueBy } 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 { createSimpleContext } from "@opencode-ai/ui/context"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { base64Encode } from "@opencode-ai/util/encode"
import { useProviders } from "@/hooks/use-providers"
import { DateTime } from "luxon"
import { persisted } from "@/utils/persist"
export type LocalFile = FileNode &
Partial<{
@@ -78,7 +80,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const agent = (() => {
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
const [store, setStore] = createStore<{
current: string
}>({
@@ -108,30 +110,62 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})()
const model = (() => {
const [store, setStore] = createStore<{
const [store, setStore, _, modelReady] = persisted(
"model.v1",
createStore<{
user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
recent: ModelKey[]
}>({
user: [],
recent: [],
}),
)
const [ephemeral, setEphemeral] = createStore<{
model: Record<string, ModelKey>
recent: ModelKey[]
}>({
model: {},
recent: [],
})
const value = localStorage.getItem("model")
setStore("recent", JSON.parse(value ?? "[]"))
createEffect(() => {
localStorage.setItem("model", JSON.stringify(store.recent))
})
const list = createMemo(() =>
const available = createMemo(() =>
providers.connected().flatMap((p) =>
Object.values(p.models).map((m) => ({
...m,
name: m.name.replace("(latest)", "").trim(),
provider: p,
latest: m.name.includes("(latest)"),
})),
),
)
const latest = createMemo(() =>
pipe(
available(),
filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6),
groupBy((x) => x.provider.id),
mapValues((models) =>
pipe(
models,
groupBy((x) => x.family),
values(),
(groups) =>
groups.flatMap((g) => {
const first = firstBy(g, [(x) => x.release_date, "desc"])
return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
}),
),
),
values(),
flat(),
),
)
const list = createMemo(() =>
available().map((m) => ({
...m,
name: m.name.replace("(latest)", "").trim(),
latest: m.name.includes("(latest)"),
})),
)
const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
const fallbackModel = createMemo(() => {
@@ -163,10 +197,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
throw new Error("No default model found")
})
const currentModel = createMemo(() => {
const current = createMemo(() => {
const a = agent.current()
const key = getFirstValidModel(
() => store.model[a.name],
() => ephemeral.model[a.name],
() => a.model,
fallbackModel,
)!
@@ -177,10 +211,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const cycle = (direction: 1 | -1) => {
const recentList = recent()
const current = currentModel()
if (!current) return
const currentModel = current()
if (!currentModel) return
const index = recentList.findIndex((x) => x?.provider.id === current.provider.id && x?.id === current.id)
const index = recentList.findIndex(
(x) => x?.provider.id === currentModel.provider.id && x?.id === currentModel.id,
)
if (index === -1) return
let next = index + direction
@@ -196,14 +232,25 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
}
function updateVisibility(model: ModelKey, visibility: "show" | "hide") {
const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
if (index >= 0) {
setStore("user", index, { visibility })
} else {
setStore("user", store.user.length, { ...model, visibility })
}
}
return {
current: currentModel,
ready: modelReady,
current,
recent,
list,
cycle,
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
batch(() => {
setStore("model", agent.current().name, model ?? fallbackModel())
setEphemeral("model", agent.current().name, model ?? fallbackModel())
if (model) updateVisibility(model, "show")
if (options?.recent && model) {
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
if (uniq.length > 5) uniq.pop()
@@ -211,6 +258,17 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
})
},
visible(model: ModelKey) {
const user = store.user.find((x) => x.modelID === model.modelID && x.providerID === model.providerID)
return (
user?.visibility !== "hide" &&
(latest().find((x) => x.modelID === model.modelID && x.providerID === model.providerID) ||
user?.visibility === "show")
)
},
setVisibility(model: ModelKey, visible: boolean) {
updateVisibility(model, visible ? "show" : "hide")
},
}
})()
@@ -349,7 +407,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
case "file.watcher.updated":
const relativePath = relative(event.properties.file)
if (relativePath.startsWith(".git/")) return
load(relativePath)
if (store.node[relativePath]) load(relativePath)
break
}
})
+39 -18
View File
@@ -1,10 +1,13 @@
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { makePersisted } from "@solid-primitives/storage"
import { useGlobalSDK } from "./global-sdk"
import { useGlobalSync } from "./global-sync"
import { Binary } from "@opencode-ai/util/binary"
import { EventSessionError } from "@opencode-ai/sdk/v2"
import { makeAudioPlayer } from "@solid-primitives/audio"
import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
import errorSound from "@opencode-ai/ui/audio/nope-03.aac"
import { persisted } from "@/utils/persist"
type NotificationBase = {
directory?: string
@@ -28,23 +31,26 @@ export type Notification = TurnCompleteNotification | ErrorNotification
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification",
init: () => {
const idlePlayer = makeAudioPlayer(idleSound)
const globalSDK = useGlobalSDK()
let idlePlayer: ReturnType<typeof makeAudioPlayer> | undefined
let errorPlayer: ReturnType<typeof makeAudioPlayer> | undefined
const [store, setStore] = makePersisted(
try {
idlePlayer = makeAudioPlayer(idleSound)
errorPlayer = makeAudioPlayer(errorSound)
} catch (err) {
console.log("Failed to load audio", err)
}
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const [store, setStore, _, ready] = persisted(
"notification.v1",
createStore({
list: [] as Notification[],
}),
{
name: "notification.v1",
},
)
// onMount(() => {
// const daysToKeep = 7
// // setStore("list", (n) => n.filter((n) => !n.viewed && n.time + 1000 * 60 * 60 * 24 * daysToKeep < Date.now()))
// })
globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
@@ -55,22 +61,36 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
}
switch (event.type) {
case "session.idle": {
idlePlayer.play()
const session = event.properties.sessionID
const sessionID = event.properties.sessionID
const [syncStore] = globalSync.child(directory)
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
const isChild = match.found && syncStore.session[match.index].parentID
if (isChild) break
try {
idlePlayer?.play()
} catch {}
setStore("list", store.list.length, {
...base,
type: "turn-complete",
session,
session: sessionID,
})
break
}
case "session.error": {
const session = event.properties.sessionID ?? "global"
// errorPlayer.play()
const sessionID = event.properties.sessionID
if (sessionID) {
const [syncStore] = globalSync.child(directory)
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
const isChild = match.found && syncStore.session[match.index].parentID
if (isChild) break
}
try {
errorPlayer?.play()
} catch {}
setStore("list", store.list.length, {
...base,
type: "error",
session,
session: sessionID ?? "global",
error: "error" in event.properties ? event.properties.error : undefined,
})
break
@@ -79,6 +99,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
})
return {
ready,
session: {
all(session: string) {
return store.list.filter((n) => n.session === session)
@@ -1,4 +1,5 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
export type Platform = {
/** Platform discriminator */
@@ -15,6 +16,9 @@ export type Platform = {
/** Open a URL in the default browser */
openLink(url: string): void
/** Storage mechanism, defaults to localStorage */
storage?: (name?: string) => SyncStorage | AsyncStorage
}
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
+111
View File
@@ -0,0 +1,111 @@
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo } from "solid-js"
import { useParams } from "@solidjs/router"
import { TextSelection } from "./local"
import { persisted } from "@/utils/persist"
interface PartBase {
content: string
start: number
end: number
}
export interface TextPart extends PartBase {
type: "text"
}
export interface FileAttachmentPart extends PartBase {
type: "file"
path: string
selection?: TextSelection
}
export interface ImageAttachmentPart {
type: "image"
id: string
filename: string
mime: string
dataUrl: string
}
export type ContentPart = TextPart | FileAttachmentPart | ImageAttachmentPart
export type Prompt = ContentPart[]
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
if (promptA.length !== promptB.length) return false
for (let i = 0; i < promptA.length; i++) {
const partA = promptA[i]
const partB = promptB[i]
if (partA.type !== partB.type) return false
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
return false
}
if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
return false
}
if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) {
return false
}
}
return true
}
function cloneSelection(selection?: TextSelection) {
if (!selection) return undefined
return { ...selection }
}
function clonePart(part: ContentPart): ContentPart {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
return {
...part,
selection: cloneSelection(part.selection),
}
}
function clonePrompt(prompt: Prompt): Prompt {
return prompt.map(clonePart)
}
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
name: "Prompt",
init: () => {
const params = useParams()
const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`)
const [store, setStore, _, ready] = persisted(
name(),
createStore<{
prompt: Prompt
cursor?: number
}>({
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
}),
)
return {
ready,
current: createMemo(() => store.prompt),
cursor: createMemo(() => store.cursor),
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
set(prompt: Prompt, cursorPosition?: number) {
const next = clonePrompt(prompt)
batch(() => {
setStore("prompt", next)
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
})
},
reset() {
batch(() => {
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
setStore("cursor", 0)
})
},
}
},
})
+1
View File
@@ -13,6 +13,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
baseUrl: globalSDK.url,
signal: abort.signal,
directory: props.directory,
throwOnError: true,
})
const emitter = createGlobalEmitter<{
-321
View File
@@ -1,321 +0,0 @@
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo } from "solid-js"
import { useSync } from "./sync"
import { makePersisted } from "@solid-primitives/storage"
import { TextSelection } from "./local"
import { pipe, sumBy } from "remeda"
import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2"
import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk"
export type LocalPTY = {
id: string
title: string
rows?: number
cols?: number
buffer?: string
scrollY?: number
}
export const { use: useSession, provider: SessionProvider } = createSimpleContext({
name: "Session",
init: () => {
const sdk = useSDK()
const params = useParams()
const sync = useSync()
const name = createMemo(() => `${params.dir}/session${params.id ? "/" + params.id : ""}.v3`)
const [store, setStore] = makePersisted(
createStore<{
messageId?: string
tabs: {
active?: string
all: string[]
}
prompt: Prompt
cursor?: number
terminals: {
active?: string
all: LocalPTY[]
}
}>({
tabs: {
all: [],
},
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
terminals: { all: [] },
}),
{
name: name(),
},
)
createEffect(() => {
if (!params.id) return
sync.session.sync(params.id)
})
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const userMessages = createMemo(() =>
messages()
.filter((m) => m.role === "user")
.sort((a, b) => a.id.localeCompare(b.id)),
)
const lastUserMessage = createMemo(() => {
return userMessages()?.at(-1)
})
const activeMessage = createMemo(() => {
if (!store.messageId) return lastUserMessage()
return userMessages()?.find((m) => m.id === store.messageId)
})
const status = createMemo(
() =>
sync.data.session_status[params.id ?? ""] ?? {
type: "idle",
},
)
const working = createMemo(() => status()?.type !== "idle")
const cost = createMemo(() => {
const total = pipe(
messages(),
sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
)
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)
})
const last = createMemo(
() => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage,
)
const model = createMemo(() =>
last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
)
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const tokens = createMemo(() => {
if (!last()) return
const tokens = last().tokens
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
})
const context = createMemo(() => {
const total = tokens()
const limit = model()?.limit.context
if (!total || !limit) return 0
return Math.round((total / limit) * 100)
})
return {
get id() {
return params.id
},
info,
status,
working,
diffs,
prompt: {
current: createMemo(() => store.prompt),
cursor: createMemo(() => store.cursor),
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
set(prompt: Prompt, cursorPosition?: number) {
const next = clonePrompt(prompt)
batch(() => {
setStore("prompt", next)
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
})
},
},
messages: {
all: messages,
user: userMessages,
last: lastUserMessage,
active: activeMessage,
setActive(message: UserMessage | undefined) {
setStore("messageId", message?.id)
},
},
usage: {
tokens,
cost,
context,
},
layout: {
tabs: store.tabs,
setActiveTab(tab: string | undefined) {
setStore("tabs", "active", tab)
},
setOpenedTabs(tabs: string[]) {
setStore("tabs", "all", tabs)
},
async openTab(tab: string) {
if (tab === "chat") {
setStore("tabs", "active", undefined)
return
}
if (tab !== "review") {
if (!store.tabs.all.includes(tab)) {
setStore("tabs", "all", [...store.tabs.all, tab])
}
}
setStore("tabs", "active", tab)
},
closeTab(tab: string) {
batch(() => {
setStore(
"tabs",
"all",
store.tabs.all.filter((x) => x !== tab),
)
if (store.tabs.active === tab) {
const index = store.tabs.all.findIndex((f) => f === tab)
const previous = store.tabs.all[Math.max(0, index - 1)]
setStore("tabs", "active", previous)
}
})
},
moveTab(tab: string, to: number) {
const index = store.tabs.all.findIndex((f) => f === tab)
if (index === -1) return
setStore(
"tabs",
"all",
produce((opened) => {
opened.splice(to, 0, opened.splice(index, 1)[0])
}),
)
},
},
terminal: {
all: createMemo(() => Object.values(store.terminals.all)),
active: createMemo(() => store.terminals.active),
new() {
sdk.client.pty.create({ title: `Terminal ${store.terminals.all.length + 1}` }).then((pty) => {
const id = pty.data?.id
if (!id) return
setStore("terminals", "all", [
...store.terminals.all,
{
id,
title: pty.data?.title ?? "Terminal",
},
])
setStore("terminals", "active", id)
})
},
update(pty: Partial<LocalPTY> & { id: string }) {
setStore("terminals", "all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
sdk.client.pty.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
},
async clone(id: string) {
const index = store.terminals.all.findIndex((x) => x.id === id)
const pty = store.terminals.all[index]
if (!pty) return
const clone = await sdk.client.pty.create({
title: pty.title,
})
if (!clone.data) return
setStore("terminals", "all", index, {
...pty,
...clone.data,
})
if (store.terminals.active === pty.id) {
setStore("terminals", "active", clone.data.id)
}
},
open(id: string) {
setStore("terminals", "active", id)
},
async close(id: string) {
batch(() => {
setStore(
"terminals",
"all",
store.terminals.all.filter((x) => x.id !== id),
)
if (store.terminals.active === id) {
const index = store.terminals.all.findIndex((f) => f.id === id)
const previous = store.tabs.all[Math.max(0, index - 1)]
setStore("terminals", "active", previous)
}
})
await sdk.client.pty.remove({ ptyID: id })
},
move(id: string, to: number) {
const index = store.terminals.all.findIndex((f) => f.id === id)
if (index === -1) return
setStore(
"terminals",
"all",
produce((all) => {
all.splice(to, 0, all.splice(index, 1)[0])
}),
)
},
},
}
},
})
interface PartBase {
content: string
start: number
end: number
}
export interface TextPart extends PartBase {
type: "text"
}
export interface FileAttachmentPart extends PartBase {
type: "file"
path: string
selection?: TextSelection
}
export type ContentPart = TextPart | FileAttachmentPart
export type Prompt = ContentPart[]
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
if (promptA.length !== promptB.length) return false
for (let i = 0; i < promptA.length; i++) {
const partA = promptA[i]
const partB = promptB[i]
if (partA.type !== partB.type) return false
if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
return false
}
if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
return false
}
}
return true
}
function cloneSelection(selection?: TextSelection) {
if (!selection) return undefined
return { ...selection }
}
function clonePart(part: ContentPart): ContentPart {
if (part.type === "text") return { ...part }
return {
...part,
selection: cloneSelection(part.selection),
}
}
function clonePrompt(prompt: Prompt): Prompt {
return prompt.map(clonePart)
}
+35
View File
@@ -4,6 +4,7 @@ import { Binary } from "@opencode-ai/util/binary"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
@@ -30,6 +31,40 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (match.found) return store.session[match.index]
return undefined
},
addOptimisticMessage(input: {
sessionID: string
text: string
parts: Part[]
agent: string
model: { providerID: string; modelID: string }
}) {
const messageID = crypto.randomUUID()
const message: Message = {
id: messageID,
sessionID: input.sessionID,
role: "user",
time: { created: Date.now() },
agent: input.agent,
model: input.model,
}
setStore(
produce((draft) => {
const messages = draft.message[input.sessionID]
if (!messages) {
draft.message[input.sessionID] = [message]
} else {
const result = Binary.search(messages, messageID, (m) => m.id)
messages.splice(result.index, 0, message)
}
draft.part[messageID] = input.parts.map((part, i) => ({
...part,
id: `${messageID}-${i}`,
sessionID: input.sessionID,
messageID,
}))
}),
)
},
async sync(sessionID: string, _isRetry = false) {
const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
+105
View File
@@ -0,0 +1,105 @@
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo } from "solid-js"
import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk"
import { persisted } from "@/utils/persist"
export type LocalPTY = {
id: string
title: string
rows?: number
cols?: number
buffer?: string
scrollY?: number
}
export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({
name: "Terminal",
init: () => {
const sdk = useSDK()
const params = useParams()
const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
const [store, setStore, _, ready] = persisted(
name(),
createStore<{
active?: string
all: LocalPTY[]
}>({
all: [],
}),
)
return {
ready,
all: createMemo(() => Object.values(store.all)),
active: createMemo(() => store.active),
new() {
sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => {
const id = pty.data?.id
if (!id) return
setStore("all", [
...store.all,
{
id,
title: pty.data?.title ?? "Terminal",
},
])
setStore("active", id)
})
},
update(pty: Partial<LocalPTY> & { id: string }) {
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
sdk.client.pty.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
},
async clone(id: string) {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
const clone = await sdk.client.pty.create({
title: pty.title,
})
if (!clone.data) return
setStore("all", index, {
...pty,
...clone.data,
})
if (store.active === pty.id) {
setStore("active", clone.data.id)
}
},
open(id: string) {
setStore("active", id)
},
async close(id: string) {
batch(() => {
setStore(
"all",
store.all.filter((x) => x.id !== id),
)
if (store.active === id) {
const index = store.all.findIndex((f) => f.id === id)
const previous = store.all[Math.max(0, index - 1)]
setStore("active", previous?.id)
}
})
await sdk.client.pty.remove({ ptyID: id })
},
move(id: string, to: number) {
const index = store.all.findIndex((f) => f.id === id)
if (index === -1) return
setStore(
"all",
produce((all) => {
all.splice(to, 0, all.splice(index, 1)[0])
}),
)
},
}
},
})
+1 -1
View File
@@ -6,8 +6,8 @@ import { createMemo } from "solid-js"
export const popularProviders = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
export function useProviders() {
const params = useParams()
const globalSync = useGlobalSync()
const params = useParams()
const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
const providers = createMemo(() => {
if (currentDirectory()) {
+44
View File
@@ -0,0 +1,44 @@
import { TextField } from "@opencode-ai/ui/text-field"
import { Logo } from "@opencode-ai/ui/logo"
import { Component } from "solid-js"
import { usePlatform } from "@/context/platform"
import { Icon } from "@opencode-ai/ui/icon"
interface ErrorPageProps {
error: any
}
export const ErrorPage: Component<ErrorPageProps> = (props) => {
const platform = usePlatform()
return (
<div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center">
<div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
<Logo class="h-8 w-auto text-text-strong" />
<div class="flex flex-col items-center gap-2 text-center">
<h1 class="text-lg font-medium text-text-strong">Something went wrong</h1>
<p class="text-sm text-text-weak">An error occurred while loading the application.</p>
</div>
<TextField
value={String(props.error?.data?.message || props.error?.message || props.error)}
readOnly
copyable
multiline
class="max-h-96 w-full font-mono text-xs no-scrollbar whitespace-pre"
label="Error Details"
hideLabel
/>
<div class="flex items-center justify-center gap-1">
Please report this error to the OpenCode team
<button
type="button"
class="flex items-center text-text-interactive-base gap-1"
onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
>
<div>on Discord</div>
<Icon name="discord" class="text-text-interactive-base" />
</button>
</div>
</div>
</div>
)
}
File diff suppressed because it is too large Load Diff
+383 -253
View File
@@ -1,4 +1,5 @@
import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo, createEffect } from "solid-js"
import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo, createEffect, on } from "solid-js"
import { Dynamic } from "solid-js/web"
import { useLocal, type LocalFile } from "@/context/local"
import { createStore } from "solid-js/store"
import { PromptInput } from "@/components/prompt-input"
@@ -11,11 +12,10 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Code } from "@opencode-ai/ui/code"
import { useCodeComponent } from "@opencode-ai/ui/context/code"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
import { SessionReview } from "@opencode-ai/ui/session-review"
import { SelectDialog } from "@opencode-ai/ui/select-dialog"
import {
DragDropProvider,
DragDropSensors,
@@ -23,31 +23,323 @@ import {
SortableProvider,
closestCenter,
createSortable,
useDragDropContext,
} from "@thisbeyond/solid-dnd"
import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import type { JSX } from "solid-js"
import { useSync } from "@/context/sync"
import { useSession, type LocalPTY } from "@/context/session"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLayout } from "@/context/layout"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { Terminal } from "@/components/terminal"
import { checksum } from "@opencode-ai/util/encode"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
import { DialogSelectModel } from "@/components/dialog-select-model"
import { useCommand } from "@/context/command"
import { useNavigate, useParams } from "@solidjs/router"
import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2"
import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt"
import { extractPromptFromParts } from "@/utils/prompt"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
export default function Page() {
const layout = useLayout()
const local = useLocal()
const sync = useSync()
const session = useSession()
const terminal = useTerminal()
const dialog = useDialog()
const codeComponent = useCodeComponent()
const command = useCommand()
const params = useParams()
const navigate = useNavigate()
const sdk = useSDK()
const prompt = usePrompt()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const revertMessageID = createMemo(() => info()?.revert?.messageID)
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const userMessages = createMemo(() =>
messages()
.filter((m) => m.role === "user")
.sort((a, b) => a.id.localeCompare(b.id)),
)
// Visible user messages excludes reverted messages (those >= revertMessageID)
const visibleUserMessages = createMemo(() => {
const revert = revertMessageID()
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
})
const lastUserMessage = createMemo(() => visibleUserMessages()?.at(-1))
const [messageStore, setMessageStore] = createStore<{ messageId?: string }>({})
const activeMessage = createMemo(() => {
if (!messageStore.messageId) return lastUserMessage()
// If the stored message is no longer visible (e.g., was reverted), fall back to last visible
const found = visibleUserMessages()?.find((m) => m.id === messageStore.messageId)
return found ?? lastUserMessage()
})
const setActiveMessage = (message: UserMessage | undefined) => {
setMessageStore("messageId", message?.id)
}
function navigateMessageByOffset(offset: number) {
const msgs = visibleUserMessages()
if (msgs.length === 0) return
const current = activeMessage()
const currentIndex = current ? msgs.findIndex((m) => m.id === current.id) : -1
let targetIndex: number
if (currentIndex === -1) {
targetIndex = offset > 0 ? 0 : msgs.length - 1
} else {
targetIndex = currentIndex + offset
}
if (targetIndex < 0 || targetIndex >= msgs.length) return
setActiveMessage(msgs[targetIndex])
}
const last = createMemo(
() => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage,
)
const model = createMemo(() =>
last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
)
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const tokens = createMemo(() => {
if (!last()) return
const t = last().tokens
return t.input + t.output + t.reasoning + t.cache.read + t.cache.write
})
const context = createMemo(() => {
const total = tokens()
const limit = model()?.limit.context
if (!total || !limit) return 0
return Math.round((total / limit) * 100)
})
const [store, setStore] = createStore({
clickTimer: undefined as number | undefined,
fileSelectOpen: false,
activeDraggable: undefined as string | undefined,
activeTerminalDraggable: undefined as string | undefined,
stepsExpanded: false,
})
let inputRef!: HTMLDivElement
const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
createEffect(() => {
if (!params.id) return
sync.session.sync(params.id)
})
createEffect(() => {
if (layout.terminal.opened()) {
if (terminal.all().length === 0) {
terminal.new()
}
}
})
createEffect(
on(
() => visibleUserMessages().at(-1)?.id,
(lastId, prevLastId) => {
if (lastId && prevLastId && lastId > prevLastId) {
setMessageStore("messageId", undefined)
}
},
{ defer: true },
),
)
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? { type: "idle" })
command.register(() => [
{
id: "session.new",
title: "New session",
description: "Create a new session",
category: "Session",
keybind: "mod+shift+s",
slash: "new",
onSelect: () => navigate(`/${params.dir}/session`),
},
{
id: "file.open",
title: "Open file",
description: "Search and open a file",
category: "File",
keybind: "mod+p",
slash: "open",
onSelect: () => dialog.show(() => <DialogSelectFile />),
},
// {
// id: "theme.toggle",
// title: "Toggle theme",
// description: "Switch between themes",
// category: "View",
// keybind: "ctrl+t",
// slash: "theme",
// onSelect: () => {
// const currentTheme = localStorage.getItem("theme") ?? "oc-1"
// const themes = ["oc-1", "oc-2-paper"]
// const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length]
// localStorage.setItem("theme", nextTheme)
// document.documentElement.setAttribute("data-theme", nextTheme)
// },
// },
{
id: "terminal.toggle",
title: "Toggle terminal",
description: "Show or hide the terminal",
category: "View",
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => layout.terminal.toggle(),
},
{
id: "terminal.new",
title: "New terminal",
description: "Create a new terminal tab",
category: "Terminal",
keybind: "ctrl+shift+`",
onSelect: () => terminal.new(),
},
{
id: "steps.toggle",
title: "Toggle steps",
description: "Show or hide the steps",
category: "View",
keybind: "mod+e",
slash: "steps",
disabled: !params.id,
onSelect: () => setStore("stepsExpanded", (x) => !x),
},
{
id: "message.previous",
title: "Previous message",
description: "Go to the previous user message",
category: "Session",
keybind: "mod+arrowup",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(-1),
},
{
id: "message.next",
title: "Next message",
description: "Go to the next user message",
category: "Session",
keybind: "mod+arrowdown",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(1),
},
{
id: "model.choose",
title: "Choose model",
description: "Select a different model",
category: "Model",
keybind: "mod+'",
slash: "model",
onSelect: () => dialog.show(() => <DialogSelectModel />),
},
{
id: "agent.cycle",
title: "Cycle agent",
description: "Switch to the next agent",
category: "Agent",
keybind: "mod+.",
slash: "agent",
onSelect: () => local.agent.move(1),
},
{
id: "agent.cycle.reverse",
title: "Cycle agent backwards",
description: "Switch to the previous agent",
category: "Agent",
keybind: "shift+mod+.",
onSelect: () => local.agent.move(-1),
},
{
id: "session.undo",
title: "Undo",
description: "Undo the last message",
category: "Session",
slash: "undo",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
if (status()?.type !== "idle") {
await sdk.client.session.abort({ sessionID }).catch(() => {})
}
const revert = info()?.revert?.messageID
// Find the last user message that's not already reverted
const message = userMessages().findLast((x) => !revert || x.id < revert)
if (!message) return
await sdk.client.session.revert({ sessionID, messageID: message.id })
// Restore the prompt from the reverted message
const parts = sync.data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts)
prompt.set(restored)
}
// Navigate to the message before the reverted one (which will be the new last visible message)
const priorMessage = userMessages().findLast((x) => x.id < message.id)
setActiveMessage(priorMessage)
},
},
{
id: "session.redo",
title: "Redo",
description: "Redo the last undone message",
category: "Session",
slash: "redo",
disabled: !params.id || !info()?.revert?.messageID,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
const revertMessageID = info()?.revert?.messageID
if (!revertMessageID) return
const nextMessage = userMessages().find((x) => x.id > revertMessageID)
if (!nextMessage) {
// Full unrevert - restore all messages and navigate to last
await sdk.client.session.unrevert({ sessionID })
prompt.reset()
// Navigate to the last message (the one that was at the revert point)
const lastMsg = userMessages().findLast((x) => x.id >= revertMessageID)
setActiveMessage(lastMsg)
return
}
// Partial redo - move forward to next message
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
// Navigate to the message before the new revert point
const priorMsg = userMessages().findLast((x) => x.id < nextMessage.id)
setActiveMessage(priorMsg)
},
},
])
const handleKeyDown = (event: KeyboardEvent) => {
if ((document.activeElement as HTMLElement)?.dataset?.component === "terminal") return
if (dialog.active) return
const focused = document.activeElement === inputRef
if (focused) {
if (event.key === "Escape") inputRef?.blur()
return
}
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
inputRef?.focus()
}
}
onMount(() => {
document.addEventListener("keydown", handleKeyDown)
@@ -57,82 +349,6 @@ export default function Page() {
document.removeEventListener("keydown", handleKeyDown)
})
createEffect(() => {
if (layout.terminal.opened()) {
if (session.terminal.all().length === 0) {
session.terminal.new()
}
}
})
const handleKeyDown = (event: KeyboardEvent) => {
if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") {
event.preventDefault()
return
}
if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") {
event.preventDefault()
setStore("fileSelectOpen", true)
return
}
if (event.ctrlKey && event.key.toLowerCase() === "t") {
event.preventDefault()
const currentTheme = localStorage.getItem("theme") ?? "oc-1"
const themes = ["oc-1", "oc-2-paper"]
const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length]
localStorage.setItem("theme", nextTheme)
document.documentElement.setAttribute("data-theme", nextTheme)
return
}
if (event.ctrlKey && event.key.toLowerCase() === "`") {
event.preventDefault()
if (event.shiftKey) {
session.terminal.new()
return
}
layout.terminal.toggle()
return
}
// @ts-expect-error
if (document.activeElement?.dataset?.component === "terminal") {
return
}
const focused = document.activeElement === inputRef
if (focused) {
if (event.key === "Escape") {
inputRef?.blur()
}
return
}
// if (local.file.active()) {
// const active = local.file.active()!
// if (event.key === "Enter" && active.selection) {
// local.context.add({
// type: "file",
// path: active.path,
// selection: { ...active.selection },
// })
// return
// }
//
// if (event.getModifierState(MOD)) {
// if (event.key.toLowerCase() === "a") {
// return
// }
// if (event.key.toLowerCase() === "c") {
// return
// }
// }
// }
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
inputRef?.focus()
}
}
const resetClickTimer = () => {
if (!store.clickTimer) return
clearTimeout(store.clickTimer)
@@ -166,11 +382,11 @@ export default function Page() {
const handleDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (draggable && droppable) {
const currentTabs = session.layout.tabs.all
const currentTabs = tabs().all()
const fromIndex = currentTabs?.indexOf(draggable.id.toString())
const toIndex = currentTabs?.indexOf(droppable.id.toString())
if (fromIndex !== toIndex && toIndex !== undefined) {
session.layout.moveTab(draggable.id.toString(), toIndex)
tabs().move(draggable.id.toString(), toIndex)
}
}
}
@@ -188,11 +404,11 @@ export default function Page() {
const handleTerminalDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (draggable && droppable) {
const terminals = session.terminal.all()
const fromIndex = terminals.findIndex((t) => t.id === draggable.id.toString())
const toIndex = terminals.findIndex((t) => t.id === droppable.id.toString())
const terminals = terminal.all()
const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
session.terminal.move(draggable.id.toString(), toIndex)
terminal.move(draggable.id.toString(), toIndex)
}
}
}
@@ -210,8 +426,8 @@ export default function Page() {
<Tabs.Trigger
value={props.terminal.id}
closeButton={
session.terminal.all().length > 1 && (
<IconButton icon="close" variant="ghost" onClick={() => session.terminal.close(props.terminal.id)} />
terminal.all().length > 1 && (
<IconButton icon="close" variant="ghost" onClick={() => terminal.close(props.terminal.id)} />
)
}
>
@@ -279,7 +495,11 @@ export default function Page() {
<div class="relative h-full">
<Tabs.Trigger
value={props.tab}
closeButton={<IconButton icon="close" variant="ghost" onClick={() => props.onTabClose(props.tab)} />}
closeButton={
<Tooltip value="Close tab" placement="bottom">
<IconButton icon="close" variant="ghost" onClick={() => props.onTabClose(props.tab)} />
</Tooltip>
}
hideCloseButton
onClick={() => props.onTabClick(props.tab)}
>
@@ -292,37 +512,7 @@ export default function Page() {
)
}
const ConstrainDragYAxis = (): JSX.Element => {
const context = useDragDropContext()
if (!context) return <></>
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
const transformer: Transformer = {
id: "constrain-y-axis",
order: 100,
callback: (transform) => ({ ...transform, y: 0 }),
}
onDragStart((event) => {
const id = getDraggableId(event)
if (!id) return
addTransformer("draggables", id, transformer)
})
onDragEnd((event) => {
const id = getDraggableId(event)
if (!id) return
removeTransformer("draggables", id, transformer.id)
})
return <></>
}
const getDraggableId = (event: unknown): string | undefined => {
if (typeof event !== "object" || event === null) return undefined
if (!("draggable" in event)) return undefined
const draggable = (event as { draggable?: { id?: unknown } }).draggable
if (!draggable) return undefined
return typeof draggable.id === "string" ? draggable.id : undefined
}
const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length)
const wide = createMemo(() => layout.review.state() === "tab" || !diffs().length)
return (
<div class="relative bg-background-base size-full overflow-x-hidden flex flex-col">
@@ -335,7 +525,7 @@ export default function Page() {
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={session.layout.tabs.active ?? "chat"} onChange={session.layout.openTab}>
<Tabs value={tabs().active() ?? "chat"} onChange={tabs().open}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List>
<Tabs.Trigger value="chat">
@@ -345,41 +535,41 @@ export default function Page() {
value={`${new Intl.NumberFormat("en-US", {
notation: "compact",
compactDisplay: "short",
}).format(session.usage.tokens() ?? 0)} Tokens`}
}).format(tokens() ?? 0)} Tokens`}
class="flex items-center gap-1.5"
>
<ProgressCircle percentage={session.usage.context() ?? 0} />
<div class="text-14-regular text-text-weak text-left w-7">{session.usage.context() ?? 0}%</div>
<ProgressCircle percentage={context() ?? 0} />
<div class="text-14-regular text-text-weak text-left w-7">{context() ?? 0}%</div>
</Tooltip>
</div>
</Tabs.Trigger>
<Show when={layout.review.state() === "tab" && session.diffs().length}>
<Show when={layout.review.state() === "tab" && diffs().length}>
<Tabs.Trigger
value="review"
closeButton={
<IconButton icon="collapse" size="normal" variant="ghost" onClick={layout.review.pane} />
<Tooltip value="Close tab" placement="bottom">
<IconButton icon="collapse" size="normal" variant="ghost" onClick={layout.review.pane} />
</Tooltip>
}
>
<div class="flex items-center gap-3">
<Show when={session.diffs()}>
<DiffChanges changes={session.diffs()} variant="bars" />
<Show when={diffs()}>
<DiffChanges changes={diffs()} variant="bars" />
</Show>
<div class="flex items-center gap-1.5">
<div>Review</div>
<Show when={session.info()?.summary?.files}>
<Show when={info()?.summary?.files}>
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
{session.info()?.summary?.files ?? 0}
{info()?.summary?.files ?? 0}
</div>
</Show>
</div>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={session.layout.tabs.all ?? []}>
<For each={session.layout.tabs.all ?? []}>
{(tab) => (
<SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={session.layout.closeTab} />
)}
<SortableProvider ids={tabs().all() ?? []}>
<For each={tabs().all() ?? []}>
{(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={tabs().close} />}
</For>
</SortableProvider>
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
@@ -388,13 +578,16 @@ export default function Page() {
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={() => setStore("fileSelectOpen", true)}
onClick={() => dialog.show(() => <DialogSelectFile />)}
/>
</Tooltip>
</div>
</Tabs.List>
</div>
<Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden">
<Tabs.Content
value="chat"
class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden contain-strict"
>
<div
classList={{
"w-full flex-1 min-h-0": true,
@@ -405,37 +598,41 @@ export default function Page() {
<div
classList={{
"relative shrink-0 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full": true,
"max-w-146 mx-auto": !wide(),
"max-w-200 mx-auto": !wide(),
}}
>
<Switch>
<Match when={session.id}>
<Match when={params.id}>
<div class="flex items-start justify-start h-full min-h-0">
<SessionMessageRail
messages={session.messages.user()}
current={session.messages.active()}
onMessageSelect={session.messages.setActive}
messages={visibleUserMessages()}
current={activeMessage()}
onMessageSelect={setActiveMessage}
wide={wide()}
/>
<SessionTurn
sessionID={session.id!}
messageID={session.messages.active()?.id!}
classes={{
root: "pb-20 flex-1 min-w-0",
content: "pb-20",
container:
"w-full " +
(wide()
? "max-w-146 mx-auto px-6"
: session.messages.user().length > 1
? "pr-6 pl-18"
: "px-6"),
}}
/>
<Show when={activeMessage()}>
<SessionTurn
sessionID={params.id!}
messageID={activeMessage()!.id}
stepsExpanded={store.stepsExpanded}
onStepsExpandedChange={(expanded) => setStore("stepsExpanded", expanded)}
classes={{
root: "pb-20 flex-1 min-w-0",
content: "pb-20",
container:
"w-full " +
(wide()
? "max-w-200 mx-auto px-6"
: visibleUserMessages().length > 1
? "pr-6 pl-18"
: "px-6"),
}}
/>
</Show>
</div>
</Match>
<Match when={true}>
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-146 mx-auto px-6">
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
<div class="text-20-medium text-text-weaker">New session</div>
<div class="flex justify-center items-center gap-3">
<Icon name="folder" size="small" />
@@ -461,7 +658,7 @@ export default function Page() {
</Match>
</Switch>
<div class="absolute inset-x-0 bottom-8 flex flex-col justify-center items-center z-50">
<div class="w-full max-w-146 px-6">
<div class="w-full max-w-200 px-6">
<PromptInput
ref={(el) => {
inputRef = el
@@ -470,10 +667,10 @@ export default function Page() {
</div>
</div>
</div>
<Show when={layout.review.state() === "pane" && session.diffs().length}>
<Show when={layout.review.state() === "pane" && diffs().length}>
<div
classList={{
"relative grow pt-3 flex-1 min-h-0 border-l border-border-weak-base": true,
"relative grow pt-3 flex-1 min-h-0 border-l border-border-weak-base contain-strict": true,
}}
>
<SessionReview
@@ -482,7 +679,7 @@ export default function Page() {
header: "px-6",
container: "px-6",
}}
diffs={session.diffs()}
diffs={diffs()}
actions={
<Tooltip value="Open in tab">
<IconButton
@@ -490,7 +687,7 @@ export default function Page() {
variant="ghost"
onClick={() => {
layout.review.tab()
session.layout.setActiveTab("review")
tabs().setActive("review")
}}
/>
</Tooltip>
@@ -500,8 +697,8 @@ export default function Page() {
</Show>
</div>
</Tabs.Content>
<Show when={layout.review.state() === "tab" && session.diffs().length}>
<Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden">
<Show when={layout.review.state() === "tab" && diffs().length}>
<Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden contain-strict">
<div
classList={{
"relative pt-3 flex-1 min-h-0 overflow-hidden": true,
@@ -513,13 +710,13 @@ export default function Page() {
header: "px-6",
container: "px-6",
}}
diffs={session.diffs()}
diffs={diffs()}
split
/>
</div>
</Tabs.Content>
</Show>
<For each={session.layout.tabs.all}>
<For each={tabs().all()}>
{(tab) => {
const [file] = createResource(
() => tab,
@@ -535,7 +732,8 @@ export default function Page() {
<Switch>
<Match when={file()}>
{(f) => (
<Code
<Dynamic
component={codeComponent}
file={{
name: f().path,
contents: f().content?.content ?? "",
@@ -573,8 +771,8 @@ export default function Page() {
</Show>
</DragOverlay>
</DragDropProvider>
<Show when={session.layout.tabs.active}>
<div class="absolute inset-x-0 px-6 max-w-146 flex flex-col justify-center items-center z-50 mx-auto bottom-8">
<Show when={tabs().active()}>
<div class="absolute inset-x-0 px-6 max-w-200 flex flex-col justify-center items-center z-50 mx-auto bottom-8">
<PromptInput
ref={(el) => {
inputRef = el
@@ -582,70 +780,6 @@ export default function Page() {
/>
</div>
</Show>
<div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto">
{/* <FileTree path="" onFileClick={ handleTabClick} /> */}
</div>
<div class="hidden shrink-0 w-56 p-2">
<Show
when={local.file.changes().length}
fallback={<div class="px-2 text-xs text-text-muted">No changes</div>}
>
<ul class="">
<For each={local.file.changes()}>
{(path) => (
<li>
<button
onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })}
class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 hover:bg-background-element"
>
<FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" />
<span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span>
<span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0">
{getDirectory(path)}
</span>
</button>
</li>
)}
</For>
</ul>
</Show>
</div>
<Show when={store.fileSelectOpen}>
<SelectDialog
defaultOpen
title="Select file"
placeholder="Search files"
emptyMessage="No files found"
items={local.file.searchFiles}
key={(x) => x}
onOpenChange={(open) => setStore("fileSelectOpen", open)}
onSelect={(x) => {
if (x) {
return session.layout.openTab("file://" + x)
}
return undefined
}}
>
{(i) => (
<div
classList={{
"w-full flex items-center justify-between rounded-md": true,
}}
>
<div class="flex items-center gap-x-2 grow min-w-0">
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(i)}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
</div>
</div>
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
</div>
)}
</SelectDialog>
</Show>
</div>
<Show when={layout.terminal.opened()}>
<div
@@ -669,25 +803,21 @@ export default function Page() {
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs variant="alt" value={session.terminal.active()} onChange={session.terminal.open}>
<Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
<Tabs.List class="h-10">
<SortableProvider ids={session.terminal.all().map((t) => t.id)}>
<For each={session.terminal.all()}>{(terminal) => <SortableTerminalTab terminal={terminal} />}</For>
<SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
<For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
</SortableProvider>
<div class="h-full flex items-center justify-center">
<Tooltip value="New Terminal" class="flex items-center">
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={session.terminal.new} />
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
</Tooltip>
</div>
</Tabs.List>
<For each={session.terminal.all()}>
{(terminal) => (
<Tabs.Content value={terminal.id}>
<Terminal
pty={terminal}
onCleanup={session.terminal.update}
onConnectError={() => session.terminal.clone(terminal.id)}
/>
<For each={terminal.all()}>
{(pty) => (
<Tabs.Content value={pty.id}>
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
</Tabs.Content>
)}
</For>
@@ -695,9 +825,9 @@ export default function Page() {
<DragOverlay>
<Show when={store.activeTerminalDraggable}>
{(draggedId) => {
const terminal = createMemo(() => session.terminal.all().find((t) => t.id === draggedId()))
const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
return (
<Show when={terminal()}>
<Show when={pty()}>
{(t) => (
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{t().title}
+26
View File
@@ -0,0 +1,26 @@
import { usePlatform } from "@/context/platform"
import { makePersisted } from "@solid-primitives/storage"
import { createResource, type Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
type InitType = Promise<string> | string | null
type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>]
export function persisted<T>(key: string, store: [Store<T>, SetStoreFunction<T>]): PersistedWithReady<T> {
const platform = usePlatform()
const [state, setState, init] = makePersisted(store, { name: key, storage: platform.storage?.() ?? localStorage })
// Create a resource that resolves when the store is initialized
// This integrates with Suspense and provides a ready signal
const isAsync = init instanceof Promise
const [ready] = createResource(
() => init,
async (initValue) => {
if (initValue instanceof Promise) await initValue
return true
},
{ initialValue: !isAsync },
)
return [state, setState, init, () => ready() === true]
}
+47
View File
@@ -0,0 +1,47 @@
import type { Part, TextPart, FilePart } from "@opencode-ai/sdk/v2"
import type { Prompt, FileAttachmentPart } from "@/context/prompt"
/**
* Extract prompt content from message parts for restoring into the prompt input.
* This is used by undo to restore the original user prompt.
*/
export function extractPromptFromParts(parts: Part[]): Prompt {
const result: Prompt = []
let position = 0
for (const part of parts) {
if (part.type === "text") {
const textPart = part as TextPart
if (!textPart.synthetic && textPart.text) {
result.push({
type: "text",
content: textPart.text,
start: position,
end: position + textPart.text.length,
})
position += textPart.text.length
}
} else if (part.type === "file") {
const filePart = part as FilePart
if (filePart.source?.type === "file") {
const path = filePart.source.path
const content = "@" + path
const attachment: FileAttachmentPart = {
type: "file",
path,
content,
start: position,
end: position + content.length,
}
result.push(attachment)
position += content.length
}
}
}
if (result.length === 0) {
result.push({ type: "text", content: "", start: 0, end: 0 })
}
return result
}
+55
View File
@@ -0,0 +1,55 @@
import { useDragDropContext } from "@thisbeyond/solid-dnd"
import { JSXElement } from "solid-js"
import type { Transformer } from "@thisbeyond/solid-dnd"
export const getDraggableId = (event: unknown): string | undefined => {
if (typeof event !== "object" || event === null) return undefined
if (!("draggable" in event)) return undefined
const draggable = (event as { draggable?: { id?: unknown } }).draggable
if (!draggable) return undefined
return typeof draggable.id === "string" ? draggable.id : undefined
}
export const ConstrainDragXAxis = (): JSXElement => {
const context = useDragDropContext()
if (!context) return <></>
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
const transformer: Transformer = {
id: "constrain-x-axis",
order: 100,
callback: (transform) => ({ ...transform, x: 0 }),
}
onDragStart((event) => {
const id = getDraggableId(event)
if (!id) return
addTransformer("draggables", id, transformer)
})
onDragEnd((event) => {
const id = getDraggableId(event)
if (!id) return
removeTransformer("draggables", id, transformer.id)
})
return <></>
}
export const ConstrainDragYAxis = (): JSXElement => {
const context = useDragDropContext()
if (!context) return <></>
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
const transformer: Transformer = {
id: "constrain-y-axis",
order: 100,
callback: (transform) => ({ ...transform, y: 0 }),
}
onDragStart((event) => {
const id = getDraggableId(event)
if (!id) return
addTransformer("draggables", id, transformer)
})
onDragEnd((event) => {
const id = getDraggableId(event)
if (!id) return
removeTransformer("draggables", id, transformer.id)
})
return <></>
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.0.152",
"version": "1.0.165",
"private": true,
"type": "module",
"scripts": {
@@ -14,7 +14,7 @@
"@opencode-ai/util": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"aws4fetch": "^1.0.20",
"@pierre/precision-diffs": "catalog:",
"@pierre/diffs": "catalog:",
"@solidjs/router": "catalog:",
"@solidjs/start": "catalog:",
"@solidjs/meta": "catalog:",
+258 -234
View File
@@ -3,6 +3,8 @@ import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { SessionReview } from "@opencode-ai/ui/session-review"
import { DataProvider } from "@opencode-ai/ui/context"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool"
import { createAsync, query, useParams } from "@solidjs/router"
import { createEffect, createMemo, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
import { Share } from "~/core/share"
@@ -19,7 +21,7 @@ import { createStore } from "solid-js/store"
import z from "zod"
import NotFound from "../[...404]"
import { Tabs } from "@opencode-ai/ui/tabs"
import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/precision-diffs/ssr"
import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { Diff as SSRDiff } from "@opencode-ai/ui/diff-ssr"
import { clientOnly } from "@solidjs/start"
import { type IconName } from "@opencode-ai/ui/icons/provider"
@@ -27,6 +29,14 @@ import { Meta } from "@solidjs/meta"
import { Base64 } from "js-base64"
const ClientOnlyDiff = clientOnly(() => import("@opencode-ai/ui/diff").then((m) => ({ default: m.Diff })))
const ClientOnlyCode = clientOnly(() => import("@opencode-ai/ui/code").then((m) => ({ default: m.Code })))
const ClientOnlyWorkerPoolProvider = clientOnly(() =>
import("@opencode-ai/ui/pierre/worker").then((m) => ({
default: (props: { children: any }) => (
<WorkerPoolProvider pool={m.workerPool}>{props.children}</WorkerPoolProvider>
),
})),
)
const SessionDataMissingError = NamedError.create(
"SessionDataMissingError",
@@ -138,18 +148,13 @@ const getData = query(async (shareID) => {
export default function () {
const params = useParams()
const data = createAsync(
async () => {
if (!params.shareID) throw new Error("Missing shareID")
const now = Date.now()
const data = getData(params.shareID)
console.log("getData", Date.now() - now)
return data
},
{
deferStream: true,
},
)
const data = createAsync(async () => {
if (!params.shareID) throw new Error("Missing shareID")
const now = Date.now()
const data = getData(params.shareID)
console.log("getData", Date.now() - now)
return data
})
createEffect(() => {
console.log(data())
@@ -200,241 +205,260 @@ export default function () {
<Meta name="description" content="opencode - The AI coding agent built for the terminal." />
<Meta property="og:image" content={ogImage()} />
<Meta name="twitter:image" content={ogImage()} />
<DiffComponentProvider component={ClientOnlyDiff}>
<DataProvider data={data()} directory={info().directory}>
{iife(() => {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
})
const messages = createMemo(() =>
data().sessionID
? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort(
(a, b) => a.time.created - b.time.created,
)
: [],
)
const firstUserMessage = createMemo(() => messages().at(0))
const activeMessage = createMemo(
() => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
)
function setActiveMessage(message: UserMessage | undefined) {
if (message) {
setStore("messageId", message.id)
} else {
setStore("messageId", undefined)
}
}
const provider = createMemo(() => activeMessage()?.model?.providerID)
const modelID = createMemo(() => activeMessage()?.model?.modelID)
const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
const diffs = createMemo(() => {
const diffs = data().session_diff[data().sessionID] ?? []
const preloaded = data().session_diff_preload[data().sessionID] ?? []
return diffs.map((diff) => ({
...diff,
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}))
})
const splitDiffs = createMemo(() => {
const diffs = data().session_diff[data().sessionID] ?? []
const preloaded = data().session_diff_preload_split[data().sessionID] ?? []
return diffs.map((diff) => ({
...diff,
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}))
})
<ClientOnlyWorkerPoolProvider>
<DiffComponentProvider component={ClientOnlyDiff}>
<CodeComponentProvider component={ClientOnlyCode}>
<DataProvider data={data()} directory={info().directory}>
{iife(() => {
const [store, setStore] = createStore({
messageId: undefined as string | undefined,
})
const messages = createMemo(() =>
data().sessionID
? (data().message[data().sessionID]?.filter((m) => m.role === "user") ?? []).sort(
(a, b) => a.time.created - b.time.created,
)
: [],
)
const firstUserMessage = createMemo(() => messages().at(0))
const activeMessage = createMemo(
() => messages().find((m) => m.id === store.messageId) ?? firstUserMessage(),
)
function setActiveMessage(message: UserMessage | undefined) {
if (message) {
setStore("messageId", message.id)
} else {
setStore("messageId", undefined)
}
}
const provider = createMemo(() => activeMessage()?.model?.providerID)
const modelID = createMemo(() => activeMessage()?.model?.modelID)
const model = createMemo(() => data().model[data().sessionID]?.find((m) => m.id === modelID()))
const diffs = createMemo(() => {
const diffs = data().session_diff[data().sessionID] ?? []
const preloaded = data().session_diff_preload[data().sessionID] ?? []
return diffs.map((diff) => ({
...diff,
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}))
})
const splitDiffs = createMemo(() => {
const diffs = data().session_diff[data().sessionID] ?? []
const preloaded = data().session_diff_preload_split[data().sessionID] ?? []
return diffs.map((diff) => ({
...diff,
preloaded: preloaded.find((d) => d.newFile.name === diff.file),
}))
})
const title = () => (
<div class="flex flex-col gap-4">
<div class="h-8 flex gap-4 items-center justify-start self-stretch">
<div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base">
<Mark class="shrink-0 w-3 my-0.5" />
<div class="text-12-mono text-text-base">v{info().version}</div>
</div>
<div class="flex gap-2 items-center">
<ProviderIcon id={provider() as IconName} class="size-3.5 shrink-0 text-icon-strong-base" />
<div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
</div>
<div class="text-12-regular text-text-weaker">
{DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
</div>
</div>
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
</div>
)
const turns = () => (
<div class="relative mt-2 pt-6 pb-8 min-w-0 w-full h-full overflow-y-auto no-scrollbar">
<div class="px-4">{title()}</div>
<div class="flex flex-col gap-15 items-start justify-start mt-4">
<For each={messages()}>
{(message) => (
<SessionTurn
sessionID={data().sessionID}
messageID={message.id}
classes={{
root: "min-w-0 w-full relative",
content:
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
container: "px-4",
}}
/>
)}
</For>
</div>
<div class="px-4 flex items-center justify-center pt-20 pb-8 shrink-0">
<Logo class="w-58.5 opacity-12" />
</div>
</div>
)
const wide = createMemo(() => diffs().length === 0)
return (
<div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
<header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
<div class="">
<a href="https://opencode.ai">
<Mark />
</a>
</div>
<div class="flex gap-3 items-center">
<IconButton
as={"a"}
href="https://github.com/sst/opencode"
target="_blank"
icon="github"
variant="ghost"
/>
<IconButton
as={"a"}
href="https://opencode.ai/discord"
target="_blank"
icon="discord"
variant="ghost"
/>
</div>
</header>
<div class="select-text flex flex-col flex-1 min-h-0">
<div
classList={{ "hidden w-full flex-1 min-h-0": true, "md:flex": wide(), "lg:flex": !wide() }}
>
<div
classList={{
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full": true,
"mx-auto max-w-146": !wide(),
}}
>
<div
classList={{
"w-full flex justify-start items-start min-w-0": true,
"max-w-146 mx-auto px-6": wide(),
"pr-6 pl-18": !wide() && messages().length > 1,
"px-6": !wide() && messages().length === 1,
}}
>
{title()}
const title = () => (
<div class="flex flex-col gap-4">
<div class="h-8 flex gap-4 items-center justify-start self-stretch">
<div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base">
<Mark class="shrink-0 w-3 my-0.5" />
<div class="text-12-mono text-text-base">v{info().version}</div>
</div>
<div class="flex items-start justify-start h-full min-h-0">
<SessionMessageRail
messages={messages()}
current={activeMessage()}
onMessageSelect={setActiveMessage}
wide={wide()}
<div class="flex gap-2 items-center">
<ProviderIcon
id={provider() as IconName}
class="size-3.5 shrink-0 text-icon-strong-base"
/>
<SessionTurn
sessionID={data().sessionID}
messageID={store.messageId ?? firstUserMessage()!.id!}
classes={{
root: "grow",
content: "flex flex-col justify-between items-start",
container:
"w-full pb-20 " +
(wide()
? "max-w-146 mx-auto px-6"
: messages().length > 1
? "pr-6 pl-18"
: "px-6"),
}}
>
<div classList={{ "w-full flex items-center justify-center pb-8 shrink-0": true }}>
<Logo class="w-58.5 opacity-12" />
</div>
</SessionTurn>
<div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
</div>
<div class="text-12-regular text-text-weaker">
{DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
</div>
</div>
<Show when={diffs().length > 0}>
<DiffComponentProvider component={SSRDiff}>
<div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
<SessionReview
class="@4xl:hidden"
diffs={diffs()}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
/>
<SessionReview
split
class="hidden @4xl:flex"
diffs={splitDiffs()}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
/>
</div>
</DiffComponentProvider>
</Show>
<div class="text-left text-16-medium text-text-strong">{info().title}</div>
</div>
<Switch>
<Match when={diffs().length > 0}>
<Tabs classList={{ "md:hidden": wide(), "lg:hidden": !wide() }}>
<Tabs.List>
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
Session
</Tabs.Trigger>
<Tabs.Trigger value="review" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}>
{diffs().length} Files Changed
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="session" class="!overflow-hidden">
{turns()}
</Tabs.Content>
<Tabs.Content
forceMount
value="review"
class="!overflow-hidden hidden data-[selected]:block"
)
const turns = () => (
<div class="relative mt-2 pb-8 min-w-0 w-full h-full overflow-y-auto no-scrollbar">
<div class="px-4 py-6">{title()}</div>
<div class="flex flex-col gap-15 items-start justify-start mt-4">
<For each={messages()}>
{(message) => (
<SessionTurn
sessionID={data().sessionID}
messageID={message.id}
classes={{
root: "min-w-0 w-full relative",
content:
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
container: "px-4",
}}
/>
)}
</For>
</div>
<div class="px-4 flex items-center justify-center pt-20 pb-8 shrink-0">
<Logo class="w-58.5 opacity-12" />
</div>
</div>
)
const wide = createMemo(() => diffs().length === 0)
return (
<div class="relative bg-background-stronger w-screen h-screen overflow-hidden flex flex-col">
<header class="h-12 px-6 py-2 flex items-center justify-between self-stretch bg-background-base border-b border-border-weak-base">
<div class="">
<a href="https://opencode.ai">
<Mark />
</a>
</div>
<div class="flex gap-3 items-center">
<IconButton
as={"a"}
href="https://github.com/sst/opencode"
target="_blank"
icon="github"
variant="ghost"
/>
<IconButton
as={"a"}
href="https://opencode.ai/discord"
target="_blank"
icon="discord"
variant="ghost"
/>
</div>
</header>
<div class="select-text flex flex-col flex-1 min-h-0">
<div
classList={{
"hidden w-full flex-1 min-h-0": true,
"md:flex": wide(),
"lg:flex": !wide(),
}}
>
<div
classList={{
"@container relative shrink-0 pt-14 flex flex-col gap-10 min-h-0 w-full": true,
"mx-auto max-w-200": !wide(),
}}
>
<div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
<DiffComponentProvider component={SSRDiff}>
<div
classList={{
"w-full flex justify-start items-start min-w-0": true,
"max-w-200 mx-auto px-6": wide(),
"pr-6 pl-18": !wide() && messages().length > 1,
"px-6": !wide() && messages().length === 1,
}}
>
{title()}
</div>
<div class="flex items-start justify-start h-full min-h-0">
<SessionMessageRail
messages={messages()}
current={activeMessage()}
onMessageSelect={setActiveMessage}
wide={wide()}
/>
<SessionTurn
sessionID={data().sessionID}
messageID={store.messageId ?? firstUserMessage()!.id!}
classes={{
root: "grow",
content: "flex flex-col justify-between",
container:
"w-full pb-20 " +
(wide()
? "max-w-200 mx-auto px-6"
: messages().length > 1
? "pr-6 pl-18"
: "px-6"),
}}
>
<div
classList={{ "w-full flex items-center justify-center pb-8 shrink-0": true }}
>
<Logo class="w-58.5 opacity-12" />
</div>
</SessionTurn>
</div>
</div>
<Show when={diffs().length > 0}>
<DiffComponentProvider component={SSRDiff}>
<div class="@container relative grow pt-14 flex-1 min-h-0 border-l border-border-weak-base">
<SessionReview
class="@4xl:hidden"
diffs={diffs()}
classes={{
root: "pb-20",
header: "px-4",
container: "px-4",
header: "px-6",
container: "px-6",
}}
/>
</DiffComponentProvider>
</div>
</Tabs.Content>
</Tabs>
</Match>
<Match when={true}>
<div classList={{ "!overflow-hidden": true, "md:hidden": wide(), "lg:hidden": !wide() }}>
{turns()}
<SessionReview
split
class="hidden @4xl:flex"
diffs={splitDiffs()}
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
/>
</div>
</DiffComponentProvider>
</Show>
</div>
</Match>
</Switch>
</div>
</div>
)
})}
</DataProvider>
</DiffComponentProvider>
<Switch>
<Match when={diffs().length > 0}>
<Tabs classList={{ "md:hidden": wide(), "lg:hidden": !wide() }}>
<Tabs.List>
<Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
Session
</Tabs.Trigger>
<Tabs.Trigger
value="review"
class="w-1/2 !border-r-0"
classes={{ button: "w-full" }}
>
{diffs().length} Files Changed
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="session" class="!overflow-hidden">
{turns()}
</Tabs.Content>
<Tabs.Content
forceMount
value="review"
class="!overflow-hidden hidden data-[selected]:block"
>
<div class="relative h-full pt-8 overflow-y-auto no-scrollbar">
<DiffComponentProvider component={SSRDiff}>
<SessionReview
diffs={diffs()}
classes={{
root: "pb-20",
header: "px-4",
container: "px-4",
}}
/>
</DiffComponentProvider>
</div>
</Tabs.Content>
</Tabs>
</Match>
<Match when={true}>
<div
classList={{ "!overflow-hidden": true, "md:hidden": wide(), "lg:hidden": !wide() }}
>
{turns()}
</div>
</Match>
</Switch>
</div>
</div>
)
})}
</DataProvider>
</CodeComponentProvider>
</DiffComponentProvider>
</ClientOnlyWorkerPoolProvider>
</>
)
}}
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.0.152"
version = "1.0.165"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/sst/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.152/opencode-darwin-arm64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.165/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.152/opencode-darwin-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.165/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.152/opencode-linux-arm64.tar.gz"
archive = "https://github.com/sst/opencode/releases/download/v1.0.165/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.152/opencode-linux-x64.tar.gz"
archive = "https://github.com/sst/opencode/releases/download/v1.0.165/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.152/opencode-windows-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.165/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.0.152",
"version": "1.0.165",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
@@ -12,7 +12,7 @@
},
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "22.0.0",
"@octokit/rest": "catalog:",
"hono": "catalog:",
"jose": "6.0.11"
}
+10 -2
View File
@@ -1,10 +1,18 @@
FROM alpine
FROM alpine AS base
# Disable the runtime transpiler cache by default inside Docker containers.
# On ephemeral containers, the cache is not useful
ARG BUN_RUNTIME_TRANSPILER_CACHE_PATH=0
ENV BUN_RUNTIME_TRANSPILER_CACHE_PATH=${BUN_RUNTIME_TRANSPILER_CACHE_PATH}
RUN apk add libgcc libstdc++ ripgrep
ADD ./dist/opencode-linux-x64-baseline-musl/bin/opencode /usr/local/bin/opencode
FROM base AS build-amd64
COPY dist/opencode-linux-x64-baseline-musl/bin/opencode /usr/local/bin/opencode
FROM base AS build-arm64
COPY dist/opencode-linux-arm64-musl/bin/opencode /usr/local/bin/opencode
ARG TARGETARCH
FROM build-${TARGETARCH}
RUN opencode --version
ENTRYPOINT ["opencode"]
+5 -5
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.152",
"version": "1.0.165",
"name": "opencode",
"type": "module",
"private": true,
@@ -64,17 +64,17 @@
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.15.1",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "22.0.0",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.0.0-20251211-4403a69a",
"@opentui/solid": "0.0.0-20251211-4403a69a",
"@opentui/core": "0.1.61",
"@opentui/solid": "0.1.61",
"@parcel/watcher": "2.5.1",
"@pierre/precision-diffs": "catalog:",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
+3
View File
@@ -117,6 +117,9 @@ for (const item of targets) {
compile: {
autoloadBunfig: false,
autoloadDotenv: false,
//@ts-ignore (bun types aren't up to date)
autoloadTsconfig: true,
autoloadPackageJson: true,
target: name.replace(pkg.name, "bun") as any,
outfile: `dist/${name}/bin/opencode`,
execArgv: [`--user-agent=opencode/${Script.version}`, "--"],
+4 -4
View File
@@ -244,8 +244,8 @@ if (!Script.preview) {
await $`cd ./dist/homebrew-tap && git push`
const image = "ghcr.io/sst/opencode"
await $`docker build -t ${image}:${Script.version} .`
await $`docker push ${image}:${Script.version}`
await $`docker tag ${image}:${Script.version} ${image}:latest`
await $`docker push ${image}:latest`
const platforms = "linux/amd64,linux/arm64"
const tags = [`${image}:${Script.version}`, `${image}:latest`]
const tagFlags = tags.flatMap((t) => ["-t", t])
await $`docker buildx build --platform ${platforms} ${tagFlags} --push .`
}
+60 -40
View File
@@ -2,18 +2,24 @@ import { Config } from "../config/config"
import z from "zod"
import { Provider } from "../provider/provider"
import { generateObject, type ModelMessage } from "ai"
import PROMPT_GENERATE from "./generate.txt"
import { SystemPrompt } from "../session/system"
import { Instance } from "../project/instance"
import { mergeDeep } from "remeda"
import PROMPT_GENERATE from "./generate.txt"
import PROMPT_COMPACTION from "./prompt/compaction.txt"
import PROMPT_EXPLORE from "./prompt/explore.txt"
import PROMPT_SUMMARY from "./prompt/summary.txt"
import PROMPT_TITLE from "./prompt/title.txt"
export namespace Agent {
export const Info = z
.object({
name: z.string(),
description: z.string().optional(),
mode: z.enum(["subagent", "primary", "all"]),
builtIn: z.boolean(),
native: z.boolean().optional(),
hidden: z.boolean().optional(),
topP: z.number().optional(),
temperature: z.number().optional(),
color: z.string().optional(),
@@ -101,6 +107,24 @@ export namespace Agent {
)
const result: Record<string, Info> = {
build: {
name: "build",
tools: { ...defaultTools },
options: {},
permission: agentPermission,
mode: "primary",
native: true,
},
plan: {
name: "plan",
options: {},
permission: planPermission,
tools: {
...defaultTools,
},
mode: "primary",
native: true,
},
general: {
name: "general",
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
@@ -112,7 +136,8 @@ export namespace Agent {
options: {},
permission: agentPermission,
mode: "subagent",
builtIn: true,
native: true,
hidden: true,
},
explore: {
name: "explore",
@@ -124,48 +149,43 @@ export namespace Agent {
...defaultTools,
},
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
prompt: [
`You are a file search specialist. You excel at thoroughly navigating and exploring codebases.`,
``,
`Your strengths:`,
`- Rapidly finding files using glob patterns`,
`- Searching code and text with powerful regex patterns`,
`- Reading and analyzing file contents`,
``,
`Guidelines:`,
`- Use Glob for broad file pattern matching`,
`- Use Grep for searching file contents with regex`,
`- Use Read when you know the specific file path you need to read`,
`- Use Bash for file operations like copying, moving, or listing directory contents`,
`- Adapt your search approach based on the thoroughness level specified by the caller`,
`- Return file paths as absolute paths in your final response`,
`- For clear communication, avoid using emojis`,
`- Do not create any files, or run bash commands that modify the user's system state in any way`,
``,
`Complete the user's search request efficiently and report your findings clearly.`,
].join("\n"),
prompt: PROMPT_EXPLORE,
options: {},
permission: agentPermission,
mode: "subagent",
builtIn: true,
native: true,
},
build: {
name: "build",
tools: { ...defaultTools },
compaction: {
name: "compaction",
mode: "primary",
native: true,
hidden: true,
prompt: PROMPT_COMPACTION,
tools: {
"*": false,
},
options: {},
permission: agentPermission,
mode: "primary",
builtIn: true,
},
plan: {
name: "plan",
options: {},
permission: planPermission,
tools: {
...defaultTools,
},
title: {
name: "title",
mode: "primary",
builtIn: true,
options: {},
native: true,
hidden: true,
permission: agentPermission,
prompt: PROMPT_TITLE,
tools: {},
},
summary: {
name: "summary",
mode: "primary",
options: {},
native: true,
hidden: true,
permission: agentPermission,
prompt: PROMPT_SUMMARY,
tools: {},
},
}
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
@@ -181,7 +201,7 @@ export namespace Agent {
permission: agentPermission,
options: {},
tools: {},
builtIn: false,
native: false,
}
const {
name,
@@ -236,9 +256,9 @@ export namespace Agent {
return state().then((x) => Object.values(x))
}
export async function generate(input: { description: string }) {
export async function generate(input: { description: string; model?: { providerID: string; modelID: string } }) {
const cfg = await Config.get()
const defaultModel = await Provider.defaultModel()
const defaultModel = input.model ?? (await Provider.defaultModel())
const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID)
const language = await Provider.getLanguage(model)
const system = SystemPrompt.header(defaultModel.providerID)
@@ -0,0 +1,18 @@
You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
Your strengths:
- Rapidly finding files using glob patterns
- Searching code and text with powerful regex patterns
- Reading and analyzing file contents
Guidelines:
- Use Glob for broad file pattern matching
- Use Grep for searching file contents with regex
- Use Read when you know the specific file path you need to read
- Use Bash for file operations like copying, moving, or listing directory contents
- Adapt your search approach based on the thoroughness level specified by the caller
- Return file paths as absolute paths in your final response
- For clear communication, avoid using emojis
- Do not create any files, or run bash commands that modify the user's system state in any way
Complete the user's search request efficiently and report your findings clearly.
@@ -22,8 +22,8 @@ Your output must be:
- The title should NEVER include "summarizing" or "generating" when generating a title
- DO NOT SAY YOU CANNOT GENERATE A TITLE OR COMPLAIN ABOUT THE INPUT
- Always output something meaningful, even if the input is minimal.
- If the user message is short or conversational (e.g. hello, lol, whats up, hey):
→ create a title that reflects the users tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.)
- If the user message is short or conversational (e.g. "hello", "lol", "whats up", "hey"):
→ create a title that reflects the user's tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.)
</rules>
<examples>
+10 -41
View File
@@ -85,47 +85,16 @@ export namespace BunProc {
version,
})
const total = 3
const wait = 500
const runInstall = async (count: number = 1): Promise<void> => {
log.info("bun install attempt", {
pkg,
version,
attempt: count,
total,
})
await BunProc.run(args, {
cwd: Global.Path.cache,
}).catch(async (error) => {
log.warn("bun install failed", {
pkg,
version,
attempt: count,
total,
error,
})
if (count >= total) {
throw new InstallFailedError(
{ pkg, version },
{
cause: error,
},
)
}
const delay = wait * count
log.info("bun install retrying", {
pkg,
version,
next: count + 1,
delay,
})
await Bun.sleep(delay)
return runInstall(count + 1)
})
}
await runInstall()
await BunProc.run(args, {
cwd: Global.Path.cache,
}).catch((e) => {
throw new InstallFailedError(
{ pkg, version },
{
cause: e,
},
)
})
// Resolve actual version from installed package when using "latest"
// This ensures subsequent starts use the cached version until explicitly updated
+10 -3
View File
@@ -3,6 +3,7 @@ import * as prompts from "@clack/prompts"
import { UI } from "../ui"
import { Global } from "../../global"
import { Agent } from "../../agent/agent"
import { Provider } from "../../provider/provider"
import path from "path"
import fs from "fs/promises"
import matter from "gray-matter"
@@ -47,6 +48,11 @@ const AgentCreateCommand = cmd({
.option("tools", {
type: "string",
describe: `comma-separated list of tools to enable (default: all). Available: "${AVAILABLE_TOOLS.join(", ")}"`,
})
.option("model", {
type: "string",
alias: ["m"],
describe: "model to use in the format of provider/model",
}),
async handler(args) {
await Instance.provide({
@@ -114,7 +120,8 @@ const AgentCreateCommand = cmd({
// Generate agent
const spinner = prompts.spinner()
spinner.start("Generating agent configuration...")
const generated = await Agent.generate({ description }).catch((error) => {
const model = args.model ? Provider.parseModel(args.model) : undefined
const generated = await Agent.generate({ description, model }).catch((error) => {
spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
if (isFullyNonInteractive) process.exit(1)
throw new UI.CancelledError()
@@ -227,8 +234,8 @@ const AgentListCommand = cmd({
async fn() {
const agents = await Agent.list()
const sortedAgents = agents.sort((a, b) => {
if (a.builtIn !== b.builtIn) {
return a.builtIn ? -1 : 1
if (a.native !== b.native) {
return a.native ? -1 : 1
}
return a.name.localeCompare(b.name)
})
+52 -19
View File
@@ -128,6 +128,19 @@ const AGENT_USERNAME = "opencode-agent[bot]"
const AGENT_REACTION = "eyes"
const WORKFLOW_FILE = ".github/workflows/opencode.yml"
// Parses GitHub remote URLs in various formats:
// - https://github.com/owner/repo.git
// - https://github.com/owner/repo
// - git@github.com:owner/repo.git
// - git@github.com:owner/repo
// - ssh://git@github.com/owner/repo.git
// - ssh://git@github.com/owner/repo
export function parseGitHubRemote(url: string): { owner: string; repo: string } | null {
const match = url.match(/^(?:(?:https?|ssh):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/)
if (!match) return null
return { owner: match[1], repo: match[2] }
}
export const GithubCommand = cmd({
command: "github",
describe: "manage GitHub agent",
@@ -197,20 +210,12 @@ export const GithubInstallCommand = cmd({
// Get repo info
const info = (await $`git remote get-url origin`.quiet().nothrow().text()).trim()
// match https or git pattern
// ie. https://github.com/sst/opencode.git
// ie. https://github.com/sst/opencode
// ie. git@github.com:sst/opencode.git
// ie. git@github.com:sst/opencode
// ie. ssh://git@github.com/sst/opencode.git
// ie. ssh://git@github.com/sst/opencode
const parsed = info.match(/^(?:(?:https?|ssh):\/\/)?(?:git@)?github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/)
const parsed = parseGitHubRemote(info)
if (!parsed) {
prompts.log.error(`Could not find git repository. Please run this command from a git repository.`)
throw new UI.CancelledError()
}
const [, owner, repo] = parsed
return { owner, repo, root: Instance.worktree }
return { owner: parsed.owner, repo: parsed.repo, root: Instance.worktree }
}
async function promptProvider() {
@@ -278,7 +283,7 @@ export const GithubInstallCommand = cmd({
process.platform === "darwin"
? `open "${url}"`
: process.platform === "win32"
? `start "${url}"`
? `start "" "${url}"`
: `xdg-open "${url}"`
exec(command, (error) => {
@@ -411,17 +416,30 @@ export const GithubRunCommand = cmd({
let exitCode = 0
type PromptFiles = Awaited<ReturnType<typeof getUserPrompt>>["promptFiles"]
const triggerCommentId = payload.comment.id
const useGithubToken = normalizeUseGithubToken()
try {
const actionToken = isMock ? args.token! : await getOidcToken()
appToken = await exchangeForAppToken(actionToken)
if (useGithubToken) {
const githubToken = process.env["GITHUB_TOKEN"]
if (!githubToken) {
throw new Error(
"GITHUB_TOKEN environment variable is not set. When using use_github_token, you must provide GITHUB_TOKEN.",
)
}
appToken = githubToken
} else {
const actionToken = isMock ? args.token! : await getOidcToken()
appToken = await exchangeForAppToken(actionToken)
}
octoRest = new Octokit({ auth: appToken })
octoGraph = graphql.defaults({
headers: { authorization: `token ${appToken}` },
})
const { userPrompt, promptFiles } = await getUserPrompt()
await configureGit(appToken)
if (!useGithubToken) {
await configureGit(appToken)
}
await assertPermissions()
await addReaction()
@@ -514,8 +532,10 @@ export const GithubRunCommand = cmd({
// Also output the clean error message for the action to capture
//core.setOutput("prepare_error", e.message);
} finally {
await restoreGitConfig()
await revokeAppToken()
if (!useGithubToken) {
await restoreGitConfig()
await revokeAppToken()
}
}
process.exit(exitCode)
@@ -544,6 +564,14 @@ export const GithubRunCommand = cmd({
throw new Error(`Invalid share value: ${value}. Share must be a boolean.`)
}
function normalizeUseGithubToken() {
const value = process.env["USE_GITHUB_TOKEN"]
if (!value) return false
if (value === "true") return true
if (value === "false") return false
throw new Error(`Invalid use_github_token value: ${value}. Must be a boolean.`)
}
function isIssueCommentEvent(
event: IssueCommentEvent | PullRequestReviewCommentEvent,
): event is IssueCommentEvent {
@@ -574,21 +602,26 @@ export const GithubRunCommand = cmd({
}
const reviewContext = getReviewCommentContext()
const mentions = (process.env["MENTIONS"] || "/opencode,/oc")
.split(",")
.map((m) => m.trim().toLowerCase())
.filter(Boolean)
let prompt = (() => {
const body = payload.comment.body.trim()
if (body === "/opencode" || body === "/oc") {
const bodyLower = body.toLowerCase()
if (mentions.some((m) => bodyLower === m)) {
if (reviewContext) {
return `Review this code change and suggest improvements for the commented lines:\n\nFile: ${reviewContext.file}\nLines: ${reviewContext.line}\n\n${reviewContext.diffHunk}`
}
return "Summarize this thread"
}
if (body.includes("/opencode") || body.includes("/oc")) {
if (mentions.some((m) => bodyLower.includes(m))) {
if (reviewContext) {
return `${body}\n\nContext: You are reviewing a comment on file "${reviewContext.file}" at line ${reviewContext.line}.\n\nDiff context:\n${reviewContext.diffHunk}`
}
return body
}
throw new Error("Comments must mention `/opencode` or `/oc`")
throw new Error(`Comments must mention ${mentions.map((m) => "`" + m + "`").join(" or ")}`)
})()
// Handle images
+7 -5
View File
@@ -88,7 +88,9 @@ export const RunCommand = cmd({
})
},
handler: async (args) => {
let message = [...args.message, ...(args["--"] || [])].join(" ")
let message = [...args.message, ...(args["--"] || [])]
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
.join(" ")
const fileParts: any[] = []
if (args.file) {
@@ -277,8 +279,8 @@ export const RunCommand = cmd({
}
return { error }
})
if (!shareResult.error) {
UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + sessionID.slice(-8))
if (!shareResult.error && "data" in shareResult && shareResult.data?.share?.url) {
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + shareResult.data.share.url)
}
}
@@ -330,8 +332,8 @@ export const RunCommand = cmd({
}
return { error }
})
if (!shareResult.error) {
UI.println(UI.Style.TEXT_INFO_BOLD + "~ https://opencode.ai/s/" + sessionID.slice(-8))
if (!shareResult.error && "data" in shareResult && shareResult.data?.share?.url) {
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + shareResult.data.share.url)
}
}
+31 -2
View File
@@ -147,6 +147,14 @@ export function tui(input: { url: string; args: Args; onExit?: () => Promise<voi
gatherStats: false,
exitOnCtrlC: false,
useKittyKeyboard: {},
consoleOptions: {
keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
onCopySelection: (text) => {
Clipboard.copy(text).catch((error) => {
console.error(`Failed to copy console selection to clipboard: ${error}`)
})
},
},
},
)
})
@@ -174,6 +182,8 @@ function App() {
// Update terminal window title based on current route and session
createEffect(() => {
if (Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return
if (route.data.type === "home") {
renderer.setTerminalTitle("OpenCode")
return
@@ -218,7 +228,9 @@ function App() {
let continued = false
createEffect(() => {
if (continued || sync.status !== "complete" || !args.continue) return
const match = sync.data.session.at(0)?.id
const match = sync.data.session
.toSorted((a, b) => b.time.updated - a.time.updated)
.find((x) => x.parentID === undefined)?.id
if (match) {
continued = true
route.navigate({ type: "session", sessionID: match })
@@ -295,6 +307,24 @@ function App() {
local.model.cycle(-1)
},
},
{
title: "Favorite cycle",
value: "model.cycle_favorite",
keybind: "model_cycle_favorite",
category: "Agent",
onSelect: () => {
local.model.cycleFavorite(1)
},
},
{
title: "Favorite cycle reverse",
value: "model.cycle_favorite_reverse",
keybind: "model_cycle_favorite_reverse",
category: "Agent",
onSelect: () => {
local.model.cycleFavorite(-1)
},
},
{
title: "Switch agent",
value: "agent.list",
@@ -454,7 +484,6 @@ function App() {
event.on(SessionApi.Event.Deleted.type, (evt) => {
if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
dialog.clear()
route.navigate({ type: "home" })
toast.show({
variant: "info",
+6 -1
View File
@@ -14,12 +14,17 @@ export const AttachCommand = cmd({
.option("dir", {
type: "string",
description: "directory to run in",
})
.option("session", {
alias: ["s"],
type: "string",
describe: "session id to continue",
}),
handler: async (args) => {
if (args.dir) process.chdir(args.dir)
await tui({
url: args.url,
args: {},
args: { sessionID: args.session },
})
},
})
@@ -12,7 +12,7 @@ export function DialogAgent() {
return {
value: item.name,
title: item.name,
description: item.builtIn ? "native" : item.description,
description: item.native ? "native" : item.description,
}
}),
)
@@ -37,7 +37,7 @@ export function DialogSessionList() {
category = "Today"
}
const isDeleting = toDelete() === x.id
const status = sync.data.session_status[x.id]
const status = sync.data.session_status?.[x.id]
const isWorking = status?.type === "busy"
return {
title: isDeleting ? `Press ${deleteKeybind} again to confirm` : x.title,
@@ -84,7 +84,6 @@ export function DialogSessionList() {
sessionID: option.value,
})
setToDelete(undefined)
// dialog.clear()
return
}
setToDelete(option.value)
@@ -11,6 +11,31 @@ export function DialogStatus() {
const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled))
const plugins = createMemo(() => {
const list = sync.data.config.plugin ?? []
const result = list.map((value) => {
if (value.startsWith("file://")) {
const path = value.substring("file://".length)
const parts = path.split("/")
const filename = parts.pop() || path
if (!filename.includes(".")) return { name: filename }
const basename = filename.split(".")[0]
if (basename === "index") {
const dirname = parts.pop()
const name = dirname || basename
return { name }
}
return { name: basename }
}
const index = value.lastIndexOf("@")
if (index <= 0) return { name: value, version: "latest" }
const name = value.substring(0, index)
const version = value.substring(index + 1)
return { name, version }
})
return result.toSorted((a, b) => a.name.localeCompare(b.name))
})
return (
<box paddingLeft={2} paddingRight={2} gap={1} paddingBottom={1}>
<box flexDirection="row" justifyContent="space-between">
@@ -109,6 +134,29 @@ export function DialogStatus() {
</For>
</box>
</Show>
<Show when={plugins().length > 0} fallback={<text fg={theme.text}>No Plugins</text>}>
<box>
<text fg={theme.text}>{plugins().length} Plugins</text>
<For each={plugins()}>
{(item) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: theme.success,
}}
>
</text>
<text wrapMode="word" fg={theme.text}>
<b>{item.name}</b>
{item.version && <span style={{ fg: theme.textMuted }}> @{item.version}</span>}
</text>
</box>
)}
</For>
</box>
</Show>
</box>
)
}
@@ -72,9 +72,13 @@ export function Autocomplete(props: {
const dims = dimensions()
positionTick()
const anchor = props.anchor()
const parent = anchor.parent
const parentX = parent?.x ?? 0
const parentY = parent?.y ?? 0
return {
x: anchor.x,
y: anchor.y,
x: anchor.x - parentX,
y: anchor.y - parentY,
width: anchor.width,
}
})
@@ -184,7 +188,7 @@ export function Autocomplete(props: {
const agents = createMemo(() => {
const agents = sync.data.agent
return agents
.filter((agent) => !agent.builtIn && agent.mode !== "primary")
.filter((agent) => !agent.hidden && agent.mode !== "primary")
.map(
(agent): AutocompleteOption => ({
display: "@" + agent.name,
@@ -357,13 +361,20 @@ export function Autocomplete(props: {
const options = createMemo(() => {
const mixed: AutocompleteOption[] = (
store.visible === "@" ? [...agents(), ...(files.loading ? files.latest || [] : files())] : [...commands()]
store.visible === "@" ? [...agents(), ...(files() || [])] : [...commands()]
).filter((x) => x.disabled !== true)
const currentFilter = filter()
if (!currentFilter) return mixed.slice(0, 10)
const result = fuzzysort.go(currentFilter, mixed, {
keys: [(obj) => obj.display.trimEnd(), "description", (obj) => obj.aliases?.join(" ") ?? ""],
limit: 10,
scoreFn: (objResults) => {
const displayResult = objResults[0]
if (displayResult && displayResult.target.startsWith(store.visible + currentFilter)) {
return objResults.score * 2
}
return objResults.score
},
})
return result.map((arr) => arr.obj)
})
@@ -9,6 +9,7 @@ import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk/v2"
export type PromptInfo = {
input: string
mode?: "normal" | "shell"
parts: (
| Omit<FilePart, "id" | "messageID" | "sessionID">
| Omit<AgentPart, "id" | "messageID" | "sessionID">
@@ -44,6 +44,7 @@ export type PromptRef = {
reset(): void
blur(): void
focus(): void
submit(): void
}
const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"]
@@ -115,7 +116,7 @@ export function Prompt(props: PromptProps) {
const sync = useSync()
const dialog = useDialog()
const toast = useToast()
const status = createMemo(() => sync.data.session_status[props.sessionID ?? ""] ?? { type: "idle" })
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
const history = usePromptHistory()
const command = useCommandDialog()
const renderer = useRenderer()
@@ -447,11 +448,14 @@ export function Prompt(props: PromptProps) {
})
setStore("extmarkToPartIndex", new Map())
},
submit() {
submit()
},
})
async function submit() {
if (props.disabled) return
if (autocomplete.visible) return
if (autocomplete?.visible) return
if (!store.prompt.input) return
const trimmed = store.prompt.input.trim()
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
@@ -491,6 +495,9 @@ export function Prompt(props: PromptProps) {
// Filter out text parts (pasted content) since they're now expanded inline
const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text")
// Capture mode before it gets reset
const currentMode = store.mode
if (store.mode === "shell") {
sdk.client.session.shell({
sessionID,
@@ -539,7 +546,10 @@ export function Prompt(props: PromptProps) {
],
})
}
history.append(store.prompt)
history.append({
...store.prompt,
mode: currentMode,
})
input.extmarks.clear()
setStore("prompt", {
input: "",
@@ -705,8 +715,8 @@ export function Prompt(props: PromptProps) {
>
<textarea
placeholder={props.sessionID ? undefined : `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
textColor={theme.text}
focusedTextColor={theme.text}
textColor={keybind.leader ? theme.textMuted : theme.text}
focusedTextColor={keybind.leader ? theme.textMuted : theme.text}
minHeight={1}
maxHeight={6}
onContentChange={() => {
@@ -763,6 +773,7 @@ export function Prompt(props: PromptProps) {
if (item) {
input.setText(item.input)
setStore("prompt", item)
setStore("mode", item.mode ?? "normal")
restoreExtmarksFromParts(item.parts)
e.preventDefault()
if (direction === -1) input.cursorOffset = 0
@@ -854,7 +865,7 @@ export function Prompt(props: PromptProps) {
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={theme.text}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model}
</text>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
@@ -869,8 +880,7 @@ export function Prompt(props: PromptProps) {
borderColor={highlight()}
customBorderChars={{
...EmptyBorder,
// when the background is transparent, don't draw the vertical line
vertical: theme.background.a != 0 ? "╹" : " ",
vertical: theme.backgroundElement.a !== 0 ? "╹" : " ",
}}
>
<box
@@ -878,7 +888,7 @@ export function Prompt(props: PromptProps) {
border={["bottom"]}
borderColor={theme.backgroundElement}
customBorderChars={
theme.background.a != 0
theme.backgroundElement.a !== 0
? {
...EmptyBorder,
horizontal: "▀",
@@ -52,7 +52,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
const agent = iife(() => {
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
const [agentStore, setAgentStore] = createStore<{
current: string
}>({
@@ -23,6 +23,7 @@ import nord from "./theme/nord.json" with { type: "json" }
import onedark from "./theme/one-dark.json" with { type: "json" }
import opencode from "./theme/opencode.json" with { type: "json" }
import orng from "./theme/orng.json" with { type: "json" }
import lucentOrng from "./theme/lucent-orng.json" with { type: "json" }
import palenight from "./theme/palenight.json" with { type: "json" }
import rosepine from "./theme/rosepine.json" with { type: "json" }
import solarized from "./theme/solarized.json" with { type: "json" }
@@ -152,6 +153,7 @@ export const DEFAULT_THEMES: Record<string, ThemeJson> = {
["one-dark"]: onedark,
opencode,
orng,
["lucent-orng"]: lucentOrng,
palenight,
rosepine,
solarized,
@@ -0,0 +1,227 @@
{
"$schema": "https://opencode.ai/theme.json",
"defs": {
"darkStep6": "#3c3c3c",
"darkStep11": "#808080",
"darkStep12": "#eeeeee",
"darkSecondary": "#EE7948",
"darkAccent": "#FFF7F1",
"darkRed": "#e06c75",
"darkOrange": "#EC5B2B",
"darkBlue": "#6ba1e6",
"darkCyan": "#56b6c2",
"darkYellow": "#e5c07b",
"lightStep6": "#d4d4d4",
"lightStep11": "#8a8a8a",
"lightStep12": "#1a1a1a",
"lightSecondary": "#EE7948",
"lightAccent": "#c94d24",
"lightRed": "#d1383d",
"lightOrange": "#EC5B2B",
"lightBlue": "#0062d1",
"lightCyan": "#318795",
"lightYellow": "#b0851f"
},
"theme": {
"primary": {
"dark": "darkOrange",
"light": "lightOrange"
},
"secondary": {
"dark": "darkSecondary",
"light": "lightSecondary"
},
"accent": {
"dark": "darkAccent",
"light": "lightAccent"
},
"error": {
"dark": "darkRed",
"light": "lightRed"
},
"warning": {
"dark": "darkOrange",
"light": "lightOrange"
},
"success": {
"dark": "darkBlue",
"light": "lightBlue"
},
"info": {
"dark": "darkCyan",
"light": "lightCyan"
},
"text": {
"dark": "darkStep12",
"light": "lightStep12"
},
"textMuted": {
"dark": "darkStep11",
"light": "lightStep11"
},
"background": {
"dark": "transparent",
"light": "transparent"
},
"backgroundPanel": {
"dark": "transparent",
"light": "transparent"
},
"backgroundElement": {
"dark": "transparent",
"light": "transparent"
},
"border": {
"dark": "darkOrange",
"light": "lightOrange"
},
"borderActive": {
"dark": "darkSecondary",
"light": "lightAccent"
},
"borderSubtle": {
"dark": "darkStep6",
"light": "lightStep6"
},
"diffAdded": {
"dark": "darkBlue",
"light": "lightBlue"
},
"diffRemoved": {
"dark": "#c53b53",
"light": "#c53b53"
},
"diffContext": {
"dark": "#828bb8",
"light": "#7086b5"
},
"diffHunkHeader": {
"dark": "#828bb8",
"light": "#7086b5"
},
"diffHighlightAdded": {
"dark": "darkBlue",
"light": "lightBlue"
},
"diffHighlightRemoved": {
"dark": "#e26a75",
"light": "#f52a65"
},
"diffAddedBg": {
"dark": "transparent",
"light": "transparent"
},
"diffRemovedBg": {
"dark": "transparent",
"light": "transparent"
},
"diffContextBg": {
"dark": "transparent",
"light": "transparent"
},
"diffLineNumber": {
"dark": "#666666",
"light": "#999999"
},
"diffAddedLineNumberBg": {
"dark": "transparent",
"light": "transparent"
},
"diffRemovedLineNumberBg": {
"dark": "transparent",
"light": "transparent"
},
"markdownText": {
"dark": "darkStep12",
"light": "lightStep12"
},
"markdownHeading": {
"dark": "darkOrange",
"light": "lightOrange"
},
"markdownLink": {
"dark": "darkOrange",
"light": "lightOrange"
},
"markdownLinkText": {
"dark": "darkCyan",
"light": "lightCyan"
},
"markdownCode": {
"dark": "darkBlue",
"light": "lightBlue"
},
"markdownBlockQuote": {
"dark": "darkAccent",
"light": "lightYellow"
},
"markdownEmph": {
"dark": "darkYellow",
"light": "lightYellow"
},
"markdownStrong": {
"dark": "darkSecondary",
"light": "lightOrange"
},
"markdownHorizontalRule": {
"dark": "darkStep11",
"light": "lightStep11"
},
"markdownListItem": {
"dark": "darkOrange",
"light": "lightOrange"
},
"markdownListEnumeration": {
"dark": "darkCyan",
"light": "lightCyan"
},
"markdownImage": {
"dark": "darkOrange",
"light": "lightOrange"
},
"markdownImageText": {
"dark": "darkCyan",
"light": "lightCyan"
},
"markdownCodeBlock": {
"dark": "darkStep12",
"light": "lightStep12"
},
"syntaxComment": {
"dark": "darkStep11",
"light": "lightStep11"
},
"syntaxKeyword": {
"dark": "darkOrange",
"light": "lightOrange"
},
"syntaxFunction": {
"dark": "darkSecondary",
"light": "lightAccent"
},
"syntaxVariable": {
"dark": "darkRed",
"light": "lightRed"
},
"syntaxString": {
"dark": "darkBlue",
"light": "lightBlue"
},
"syntaxNumber": {
"dark": "darkAccent",
"light": "lightOrange"
},
"syntaxType": {
"dark": "darkYellow",
"light": "lightYellow"
},
"syntaxOperator": {
"dark": "darkCyan",
"light": "lightCyan"
},
"syntaxPunctuation": {
"dark": "darkStep12",
"light": "lightStep12"
}
}
}
@@ -57,6 +57,7 @@ export function Home() {
} else if (args.prompt) {
prompt.set({ input: args.prompt, parts: [] })
once = true
prompt.submit()
}
})
const directory = useDirectory()
@@ -64,6 +64,7 @@ import { Editor } from "../../util/editor"
import stripAnsi from "strip-ansi"
import { Footer } from "./footer.tsx"
import { usePromptRef } from "../../context/prompt"
import { Filesystem } from "@/util/filesystem"
addDefaultParsers(parsers.parsers)
@@ -224,7 +225,7 @@ export function Session() {
const parentID = session()?.parentID ?? session()?.id
let children = sync.data.session
.filter((x) => x.parentID === parentID || x.id === parentID)
.toSorted((b, a) => a.id.localeCompare(b.id))
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
if (children.length === 1) return
let next = children.findIndex((x) => x.id === session()?.id) + direction
if (next >= children.length) next = 0
@@ -323,10 +324,13 @@ export function Session() {
keybind: "session_unshare",
disabled: !session()?.share?.url,
category: "Session",
onSelect: (dialog) => {
sdk.client.session.unshare({
sessionID: route.sessionID,
})
onSelect: async (dialog) => {
await sdk.client.session
.unshare({
sessionID: route.sessionID,
})
.then(() => toast.show({ message: "Session unshared successfully", variant: "success" }))
.catch(() => toast.show({ message: "Failed to unshare session", variant: "error" }))
dialog.clear()
},
},
@@ -336,7 +340,7 @@ export function Session() {
keybind: "messages_undo",
category: "Session",
onSelect: async (dialog) => {
const status = sync.data.session_status[route.sessionID]
const status = sync.data.session_status?.[route.sessionID]
if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {})
const revert = session().revert?.messageID
const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user")
@@ -836,6 +840,9 @@ export function Session() {
</Show>
<scrollbox
ref={(r) => (scroll = r)}
viewportOptions={{
paddingRight: showScrollbar() ? 1 : 0,
}}
verticalScrollbarOptions={{
paddingLeft: 1,
visible: showScrollbar(),
@@ -1411,22 +1418,29 @@ ToolRegistry.register<typeof WriteTool>({
return props.input.content
})
const diagnostics = createMemo(() => props.metadata.diagnostics?.[props.input.filePath ?? ""] ?? [])
const diagnostics = createMemo(() => {
const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
return props.metadata.diagnostics?.[filePath] ?? []
})
const done = !!props.input.filePath
return (
<>
<ToolTitle icon="←" fallback="Preparing write..." when={props.input.filePath}>
<ToolTitle icon="←" fallback="Preparing write..." when={done}>
Wrote {props.input.filePath}
</ToolTitle>
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
<code
conceal={false}
fg={theme.text}
filetype={filetype(props.input.filePath!)}
syntaxStyle={syntax()}
content={code()}
/>
</line_number>
<Show when={done}>
<line_number fg={theme.textMuted} minWidth={3} paddingRight={1}>
<code
conceal={false}
fg={theme.text}
filetype={filetype(props.input.filePath!)}
syntaxStyle={syntax()}
content={code()}
/>
</line_number>
</Show>
<Show when={diagnostics().length}>
<For each={diagnostics()}>
{(diagnostic) => (
@@ -1584,7 +1598,8 @@ ToolRegistry.register<typeof EditTool>({
const diffContent = createMemo(() => props.metadata.diff ?? props.permission["diff"])
const diagnostics = createMemo(() => {
const arr = props.metadata.diagnostics?.[props.input.filePath ?? ""] ?? []
const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
const arr = props.metadata.diagnostics?.[filePath] ?? []
return arr.filter((x) => x.severity === 1).slice(0, 3)
})
@@ -9,6 +9,7 @@ import { Global } from "@/global"
import { Installation } from "@/installation"
import { useKeybind } from "../../context/keybind"
import { useDirectory } from "../../context/directory"
import { useKV } from "../../context/kv"
export function Sidebar(props: { sessionID: string }) {
const sync = useSync()
@@ -48,12 +49,13 @@ export function Sidebar(props: { sessionID: string }) {
}
})
const keybind = useKeybind()
const directory = useDirectory()
const kv = useKV()
const hasProviders = createMemo(() =>
sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)),
)
const gettingStartedDismissed = createMemo(() => kv.get("dismissed_getting_started", false))
return (
<Show when={session()}>
@@ -249,7 +251,7 @@ export function Sidebar(props: { sessionID: string }) {
</scrollbox>
<box flexShrink={0} gap={1} paddingTop={1}>
<Show when={!hasProviders()}>
<Show when={!hasProviders() && !gettingStartedDismissed()}>
<box
backgroundColor={theme.backgroundElement}
paddingTop={1}
@@ -263,9 +265,14 @@ export function Sidebar(props: { sessionID: string }) {
</text>
<box flexGrow={1} gap={1}>
<text fg={theme.text}>
<b>Getting started</b>
</text>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.text}>
<b>Getting started</b>
</text>
<text fg={theme.textMuted} onMouseDown={() => kv.set("dismissed_getting_started", true)}>
</text>
</box>
<text fg={theme.textMuted}>OpenCode includes free models so you can start immediately.</text>
<text fg={theme.textMuted}>
Connect from 75+ providers to use other models, including Claude, GPT, Gemini etc
@@ -277,7 +284,10 @@ export function Sidebar(props: { sessionID: string }) {
</box>
</box>
</Show>
<text fg={theme.text}>{directory()}</text>
<text>
<span style={{ fg: theme.textMuted }}>{directory().split("/").slice(0, -1).join("/")}/</span>
<span style={{ fg: theme.text }}>{directory().split("/").at(-1)}</span>
</text>
<text fg={theme.textMuted}>
<span style={{ fg: theme.success }}></span> <b>Open</b>
<span style={{ fg: theme.text }}>
@@ -1,7 +1,7 @@
import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core"
import { useTheme, selectedForeground } from "@tui/context/theme"
import { entries, filter, flatMap, groupBy, pipe, take } from "remeda"
import { batch, createEffect, createMemo, For, Show, type JSX } from "solid-js"
import { batch, createEffect, createMemo, For, Show, type JSX, on } from "solid-js"
import { createStore } from "solid-js/store"
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import * as fuzzysort from "fuzzysort"
@@ -53,14 +53,19 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
filter: "",
})
createEffect(() => {
if (props.current) {
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, props.current))
if (currentIndex >= 0) {
setStore("selected", currentIndex)
}
}
})
createEffect(
on(
() => props.current,
(current) => {
if (current) {
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
if (currentIndex >= 0) {
setStore("selected", currentIndex)
}
}
},
),
)
let input: InputRenderable
@@ -98,18 +103,19 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const selected = createMemo(() => flat()[store.selected])
createEffect(() => {
store.filter
if (store.filter.length > 0) {
setStore("selected", 0)
} else if (props.current) {
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, props.current))
if (currentIndex >= 0) {
setStore("selected", currentIndex)
createEffect(
on([() => store.filter, () => props.current], ([filter, current]) => {
if (filter.length > 0) {
setStore("selected", 0)
} else if (current) {
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
if (currentIndex >= 0) {
setStore("selected", currentIndex)
}
}
}
scroll.scrollTo(0)
})
scroll.scrollTo(0)
}),
)
function move(direction: number) {
let next = store.selected + direction
@@ -307,10 +313,9 @@ function Option(props: {
fg={props.active ? fg : props.current ? theme.primary : theme.text}
attributes={props.active ? TextAttributes.BOLD : undefined}
overflow="hidden"
wrapMode="word"
paddingLeft={3}
>
{Locale.truncate(props.title, 62)}
{Locale.truncate(props.title, 61)}
<Show when={props.description}>
<span style={{ fg: props.active ? fg : theme.textMuted }}> {props.description}</span>
</Show>
+20 -4
View File
@@ -5,7 +5,7 @@ import os from "os"
import z from "zod"
import { Filesystem } from "../util/filesystem"
import { ModelsDev } from "../provider/models"
import { mergeDeep, pipe } from "remeda"
import { mergeDeep, pipe, unique } from "remeda"
import { Global } from "../global"
import fs from "fs/promises"
import { lazy } from "../util/lazy"
@@ -76,6 +76,13 @@ export namespace Config {
stop: Instance.worktree,
}),
)),
...(await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
start: Global.Path.home,
stop: Global.Path.home,
}),
)),
]
if (Flag.OPENCODE_CONFIG_DIR) {
@@ -84,7 +91,7 @@ export namespace Config {
}
const promises: Promise<void>[] = []
for (const dir of directories) {
for (const dir of unique(directories)) {
await assertValid(dir)
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
@@ -211,7 +218,7 @@ export namespace Config {
result[config.name] = parsed.data
continue
}
throw new InvalidError({ path: item }, { cause: parsed.error })
throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error })
}
return result
}
@@ -254,7 +261,7 @@ export namespace Config {
result[config.name] = parsed.data
continue
}
throw new InvalidError({ path: item }, { cause: parsed.error })
throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error })
}
return result
}
@@ -460,6 +467,8 @@ export namespace Config {
model_list: z.string().optional().default("<leader>m").describe("List available models"),
model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"),
model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"),
model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"),
model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"),
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
@@ -668,10 +677,16 @@ export namespace Config {
.describe("@deprecated Use `agent` field instead."),
agent: z
.object({
// primary
plan: Agent.optional(),
build: Agent.optional(),
// subagent
general: Agent.optional(),
explore: Agent.optional(),
// specialized
title: Agent.optional(),
summary: Agent.optional(),
compaction: Agent.optional(),
})
.catchall(Agent)
.optional()
@@ -783,6 +798,7 @@ export namespace Config {
.array(z.string())
.optional()
.describe("Tools that should only be available to primary agents."),
continue_loop_on_deny: z.boolean().optional().describe("Continue the agent loop when a tool call is denied"),
})
.optional(),
})
+26
View File
@@ -3,14 +3,20 @@ import { Log } from "../util/log"
export namespace FileTime {
const log = Log.create({ service: "file.time" })
// Per-session read times plus per-file write locks.
// All tools that overwrite existing files should run their
// assert/read/write/update sequence inside withLock(filepath, ...)
// so concurrent writes to the same file are serialized.
export const state = Instance.state(() => {
const read: {
[sessionID: string]: {
[path: string]: Date | undefined
}
} = {}
const locks = new Map<string, Promise<void>>()
return {
read,
locks,
}
})
@@ -25,6 +31,26 @@ export namespace FileTime {
return state().read[sessionID]?.[file]
}
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
const current = state()
const currentLock = current.locks.get(filepath) ?? Promise.resolve()
let release: () => void = () => {}
const nextLock = new Promise<void>((resolve) => {
release = resolve
})
const chained = currentLock.then(() => nextLock)
current.locks.set(filepath, chained)
await currentLock
try {
return await fn()
} finally {
release()
if (current.locks.get(filepath) === chained) {
current.locks.delete(filepath)
}
}
}
export async function assert(sessionID: string, filepath: string) {
const time = get(sessionID, filepath)
if (!time) throw new Error(`You must read the file ${filepath} before overwriting it. Use the Read tool first`)

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