Compare commits

..
503 Commits
Author SHA1 Message Date
opencode 66563fb974 release: v1.0.193
Update Nix Hashes / update (push) Has been cancelled
2025-12-23 15:16:20 +00:00
GitHub Action fbece0dc4d chore: generate 2025-12-23 14:45:27 +00:00
Sebastian Herrlinger 1d9e181da0 indent wrapped todo items properly 2025-12-23 15:44:45 +01:00
opencode c81721e9fc release: v1.0.192
Update Nix Hashes / update (push) Has been cancelled
2025-12-23 14:43:07 +00:00
Frank a94899ed36 zen: glm 4.7 2025-12-23 07:56:37 -05:00
Frank b18d22498c ci: fix 2025-12-23 07:53:33 -05:00
Frank c75584a31b ci: fix 2025-12-23 07:38:42 -05:00
GitHub Action b474f65547 ignore: update download stats 2025-12-23 2025-12-23 12:04:42 +00:00
Sebastian Herrlinger c352999b41 no intermediate autocomplete result to avoid flickering 2025-12-23 12:22:34 +01:00
GitHub Action f4cd708ca0 chore: generate 2025-12-23 10:15:49 +00:00
Brendan Allan c20f2731ab desktop: kill_sidecar before update install on windows 2025-12-23 18:14:44 +08:00
Github Action 01ca1a384a Update Nix flake.lock and hashes 2025-12-23 10:12:37 +00:00
GitHub Action f330dadd89 chore: generate 2025-12-23 10:11:53 +00:00
Adam 43e92b4932 deps: diffs, shiki updates 2025-12-23 04:08:42 -06:00
opencode 83397ebde2 release: v1.0.191
Update Nix Hashes / update (push) Has been cancelled
2025-12-23 05:57:23 +00:00
GitHub Action fde74a72bb chore: generate 2025-12-23 05:53:02 +00:00
Brendan Allan 10ee8e5b3d console: add AppImage download link 2025-12-23 13:52:23 +08:00
GitHub Action 96d3f1fe7c chore: generate 2025-12-23 04:28:11 +00:00
Matt SilverlockandGitHub 1a2b656c4d improve mcp CLI + ability to debug MCP oauth (#5980) 2025-12-22 22:27:38 -06:00
Aiden Cline 161e9287a8 ci: docs sync 2025-12-22 22:27:21 -06:00
opencode-agent[bot]GitHubopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
968543af39 docs: new /global/health API (#6006)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2025-12-22 22:26:47 -06:00
5af35117db fix: handle Windows CRLF line endings in grep tool (#5948)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-22 22:26:15 -06:00
Joel HooksandGitHub eab177f5e7 feat(plugin): allow compaction hook to replace prompt entirely (#5907) 2025-12-22 22:19:14 -06:00
Brendan Allan 279dc04b3c ci: rename tauri -> desktop 2025-12-23 11:15:19 +08:00
Github Action cbc5903aa1 Update Nix flake.lock and hashes 2025-12-23 02:03:30 +00:00
Adam 81c3c63895 chore: rename packages/tauri -> packages/desktop 2025-12-22 20:01:25 -06:00
Github Action b76bd4141d Update Nix flake.lock and hashes 2025-12-23 01:40:34 +00:00
Adam 794fe8f381 chore: rename packages/desktop -> packages/app 2025-12-22 19:39:00 -06:00
GitHub Action a4eebf9f08 chore: generate 2025-12-23 01:17:33 +00:00
Adam 680a63e3de fix(desktop): better error messages on connection failure 2025-12-22 19:16:54 -06:00
Mohammad AlhashemiandGitHub 3a54ab68d1 feat(skill): add per-agent filtering to skill tool description (#6000) 2025-12-22 20:14:33 -05:00
Frank 44fd0eee64 zen: glm 4.7 2025-12-22 19:36:07 -05:00
Aiden ClineandGitHub ac371d2987 feat: better styling for small screens (short and/or not wide) (#5968) 2025-12-22 18:00:26 -06:00
GitHub Action a7baa5ce18 chore: generate 2025-12-22 23:40:52 +00:00
Dax Raad b129f809b9 tui: change task tool container to block layout for better subagent session display 2025-12-22 18:40:15 -05:00
opencode 92c0ab51e2 release: v1.0.190
Update Nix Hashes / update (push) Has been cancelled
2025-12-22 23:31:20 +00:00
GitHub Action b25418e68b chore: generate 2025-12-22 23:24:39 +00:00
046e351140 feat: add native skill tool with permission system (#5930)
Co-authored-by: Dax Raad <d@ironbay.co>
2025-12-22 18:24:06 -05:00
opencode b9029afa22 release: v1.0.189
Update Nix Hashes / update (push) Has been cancelled
2025-12-22 23:15:23 +00:00
GitHub Actionandopencode b229aeec0b chore: generate 2025-12-22 22:47:22 +00:00
Jay Vandopencode c9140c6bab docs: edit gitlab 2025-12-22 22:47:22 +00:00
opencode 38551bda38 release: v1.0.188
Update Nix Hashes / update (push) Has been cancelled
2025-12-22 22:47:21 +00:00
Github Action cd16d31510 Update Nix flake.lock and hashes 2025-12-22 22:37:48 +00:00
Frank 54ba1af5d6 remove sharp 2025-12-22 17:36:23 -05:00
Josh ThomasandGitHub fe3144ce5b fix(tui): resize textarea if text inserted via appendPrompt TUI API (#5983) 2025-12-22 16:29:18 -06:00
Frank a1c0bae3af zen: add glm 4.7 2025-12-22 17:23:33 -05:00
Aiden Cline 85f8655dfd ignore: agents.md 2025-12-22 16:21:56 -06:00
Adam 9b6c9f64f7 feat(desktop): review pane toggle 2025-12-22 16:20:17 -06:00
Viktor NagyandJay V 1aae1c795d Add gitlab-opencode to GitLab docs
The current GitLab page describes OpenCode integration through GitLab Duo.

GitLab Duo is a paying functionality and is limited to workflows supported by GitLab.

GitLab-OpenCode is a community project that offers more flexiblity, better customization and easier setup to use OpenCode in GitLab. On the downside, it does not have the level of integration into GitLab as Duo does.
2025-12-22 17:14:59 -05:00
Frank 526c723e62 support glm 4.7 2025-12-22 17:11:02 -05:00
GitHub Action 6011200128 chore: generate 2025-12-22 22:01:27 +00:00
Jay V 740fcd243c ignore: update GitHub stars to 41K and project stats to reflect current growth 2025-12-22 17:00:27 -05:00
opencode e4d8a117c4 release: v1.0.187
Update Nix Hashes / update (push) Has been cancelled
2025-12-22 21:58:41 +00:00
Aiden Cline 8c4a816cf6 ci: add failure case for changelog 2025-12-22 15:53:41 -06:00
Aiden Cline 5605fc3f38 test: rm claude skills test 2025-12-22 15:45:31 -06:00
Aiden Cline 009b096004 fix: disable claude skill loading for now 2025-12-22 15:40:08 -06:00
64f898601b fix: stop auto execute on sendText vscode extension (#5994)
Co-authored-by: Shpetim <shpetim.alimi@ndbit.net>
2025-12-22 15:38:54 -06:00
Jon RedekerandGitHub 224e5466c1 docs: add opencode-morph-fast-apply plugin to ecosystem (#5992) 2025-12-22 15:21:14 -06:00
Blake NorthandGitHub 87b5b34280 fix(providers.opencode): check config for api key in addition to auth (#5906) 2025-12-22 15:20:40 -06:00
Github Action 855fd07d22 Update Nix flake.lock and hashes 2025-12-22 21:13:50 +00:00
Aiden Cline f9be2bab3a fix: bundle more providers to fix breaking ai sdk issue 2025-12-22 15:12:18 -06:00
Rohan GodhaandGitHub 25f1643e8e feat(tui): go to parent keybind for subagents (#5762) 2025-12-22 14:50:45 -06:00
GitHub Action e015bea462 chore: generate 2025-12-22 20:34:21 +00:00
wienansandGitHub 7dc55ac3ca Add OpenChamber to ecosystem documentation (#5978) 2025-12-22 14:33:45 -06:00
jaandGitHub cd8ecf9722 feat(lsp): add Tinymist LSP support for Typst (#5933) 2025-12-22 14:31:47 -06:00
Github Action eb021a5f92 Update Nix flake.lock and hashes 2025-12-22 20:27:29 +00:00
Sebastian Herrlinger 7f5e30834f upgrade opentui to v0.1.63, enabling kitty alternate keys by default 2025-12-22 21:26:03 +01:00
Tim KleinschmidtandGitHub 750a936ae1 support clojure projects with built-in lsp (#5975) 2025-12-22 14:20:15 -06:00
ShpetimandGitHub 8dfef670b3 [FEATURE]: Show context usage in OpenCode Desktop Context usage (#5979) 2025-12-22 13:56:36 -06:00
Adam 1b1b73b5b3 fix(prompt): better summary prompt 2025-12-22 13:09:12 -06:00
Daniel PolitoandGitHub 6baee0791f docs: Github Auto Pull Request Docs (#5974) 2025-12-22 11:53:58 -06:00
Adam 291b65977c chore(desktop): auto scroll utility 2025-12-22 11:27:27 -06:00
GitHub Action 90f232d7f1 chore: generate 2025-12-22 17:06:35 +00:00
Will MarellaGitHubWill@Cambridge <willcambridge@MacBook-Pro-59.local>Will@Cambridge <willcambridge@macbookpro.mynetworksettings.com>
af214d35cb Add keybindable commands to navigate between user messages (#5078)
Co-authored-by: Will@Cambridge <willcambridge@MacBook-Pro-59.local>
Co-authored-by: Will@Cambridge <willcambridge@macbookpro.mynetworksettings.com>
2025-12-22 11:06:00 -06:00
Aiden Cline 3f0afd7cf6 ci: tweak docs prompt 2025-12-22 11:00:32 -06:00
Daniel PolitoandGitHub 0545c5da2d GitHub pull request event (#5335) 2025-12-22 10:59:02 -06:00
Adam 4a32fa6f02 fix(share): expanded state and responsiveness 2025-12-22 10:13:57 -06:00
Aiden Cline 29c99ed4ab ci: limit to opencode repo 2025-12-22 09:56:51 -06:00
Lekë DobrunaandGitHub 753abbe164 fix: duplicate words in dialog options (#5944) 2025-12-22 09:56:32 -06:00
Adam 8e01f6cc13 fix(desktop): diff readability (colors) 2025-12-22 09:51:21 -06:00
Dax Raad 33c0b125cb fix url for web 2025-12-22 10:45:51 -05:00
GitHub Action dab2e54df8 chore: generate 2025-12-22 14:38:20 +00:00
60db171b44 fix(read): narrow .env file blocking to not block .envrc (#5654)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-22 08:37:43 -06:00
opencode c6e9a5c800 release: v1.0.186
Update Nix Hashes / update (push) Has been cancelled
2025-12-22 12:14:06 +00:00
Adam 2c16b9fa61 Revert "server: ensure frontend has correct port for PTY websocket connections (#5898)"
This reverts commit a05915ddc8.
2025-12-22 06:05:46 -06:00
Adam 240ad31edd Revert "fix: server"
This reverts commit dbaac79039.
2025-12-22 06:05:46 -06:00
GitHub Action a97631f769 ignore: update download stats 2025-12-22 2025-12-22 12:05:15 +00:00
Adam dbaac79039 fix: server 2025-12-22 06:02:16 -06:00
Ashutosh KumarandGitHub a05915ddc8 server: ensure frontend has correct port for PTY websocket connections (#5898) 2025-12-22 05:56:23 -06:00
Adam eebbd73346 Revert "fix: use current page port instead of hardcoded 4096 (#5949)"
This reverts commit d04a72a4ad.
2025-12-22 05:55:15 -06:00
Adam d4c981495a fix(desktop): cleanup auto scroll 2025-12-22 05:46:07 -06:00
Adam 653c206688 feat(desktop): mobile responsiveness 2025-12-22 05:46:07 -06:00
Adam 580f46b589 fix(desktop): filter child sessions from header 2025-12-22 05:46:07 -06:00
Adam 986d12fd20 feat(desktop): better task tool rendering 2025-12-22 05:46:06 -06:00
d04a72a4ad fix: use current page port instead of hardcoded 4096 (#5949)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-22 05:45:08 -06:00
Aaron IkerandGitHub 5fd873a35a feat: polish dialog & list styles for the desktop app, add fixed logos from models.dev (#5925) 2025-12-22 05:41:38 -06:00
Brendan Allan a9fbd786b3 ci: fix tauri build args 2025-12-22 18:55:01 +08:00
Brendan Allan abde984b3e ci: verbose build and re-enable appimage 2025-12-22 18:48:58 +08:00
GitHub Action a95aa037a3 chore: generate 2025-12-22 10:06:29 +00:00
Brendan Allan 11a92b24c2 ci: run prepare step for tauri build 2025-12-22 18:05:52 +08:00
Brendan Allan f9c10c62d8 ci: try downloading artifact in desktop prepare 2025-12-22 17:45:57 +08:00
NN708andGitHub 6339f39871 feat(desktop): arm64 build for linux (#5935) 2025-12-22 03:45:30 -06:00
Brendan Allan 68b09b30a1 ci: replace with just upload-artifact whole dir 2025-12-22 17:17:17 +08:00
Brendan Allan 92ade2a320 ci: import bun shell 2025-12-22 17:10:43 +08:00
Brendan Allan cb1a1fb26c try uploading artifacts in workflow 2025-12-22 17:08:44 +08:00
Brendan Allan af5ebabd03 remove actions artifact uploading 2025-12-22 17:01:52 +08:00
Github Action fe2626a4ea Update Nix flake.lock and hashes 2025-12-22 08:58:57 +00:00
GitHub Action 45447e3336 chore: generate 2025-12-22 08:58:16 +00:00
Brendan Allan 7a3e82ec5d ci: try to upload cli artifacts 2025-12-22 16:57:28 +08:00
Aiden ClineandGitHub 345f4801e8 feat: add experimental lsp tool (#5886) 2025-12-22 00:34:21 -06:00
Luo ChenandGitHub ac4b8d62e3 feat: add nixd as lsp for nix language (#5929) 2025-12-22 00:30:08 -06:00
opencode-agent[bot]GitHubopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-nodeAiden Cline
236ce7a8c0 docs: Agent Skills (#5931)
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-21 23:49:28 -06:00
8bdc0c8f79 fix: ensure installation commands are using .quiet (#5758)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-12-21 23:44:25 -06:00
Ben VargasandGitHub 04650f01fe docs: add ai-sdk-provider-opencode-sdk to ecosystem (#5772) 2025-12-21 22:59:36 -06:00
Aiden Cline 02d4594abf ci: update docs prompt 2025-12-21 22:32:04 -06:00
Aiden Cline c1894b4e3d ci: add automatic doc update workflow 2025-12-21 21:52:44 -06:00
Neil DaquioagandGitHub 2062247e72 fix: support clipboard image paste (Ctrl+V) on Windows (#5919) 2025-12-21 21:18:47 -06:00
Aiden Cline 8785bec29c tweak: adjust minimax m2 topK and add minimax m2.1 topP 2025-12-21 21:17:58 -06:00
d4b7f75ce3 fix: Perform snapshot in cases finish-step is not reached (#5912)
Co-authored-by: noamzbr <noamzbr@users.noreply.github.com>
2025-12-21 21:13:11 -06:00
Matt SilverlockandGitHub 4f73d58031 prompts: improve built-in /review prompt (#5918) 2025-12-21 21:11:48 -06:00
YeonGyu-KimandGitHub b906f2de88 feat(server): expose auto param in session.summarize for plugins (#5924) 2025-12-21 21:05:30 -06:00
GitHub Action 4035afe5c8 chore: generate 2025-12-22 00:45:30 +00:00
DaxandGitHub 8fe0715928 feat: add Agent Skills support (#5921) 2025-12-21 19:44:56 -05:00
opencode cb8af962cd release: v1.0.185
Update Nix Hashes / update (push) Has been cancelled
2025-12-21 23:38:40 +00:00
Dax Raad c333ffa38b core: fix LSP server binary installation and shell command execution
- Ensure proper file permissions are set for installed LSP binaries on non-Windows platforms
- Add error handling for shell command execution in prompt system to prevent crashes
2025-12-21 18:33:37 -05:00
Aiden Cline 3456f4ed80 tweak: update kimi-k2 and kimi-k2-thinking to use recommended temperature values 2025-12-21 15:54:57 -06:00
Dax Raad 2536e9f45b tui: fix SDK context usage and server port fallback
- Update SDK context to return client instead of event for proper usage
- Add server port fallback to 4096 when port 0 is specified but unavailable
- Fix SDK event listener usage in TUI app
2025-12-21 14:57:55 -05:00
Github Action 9188bc542c Update Nix flake.lock and hashes 2025-12-21 17:46:30 +00:00
GitHub Action cbaba10994 chore: generate 2025-12-21 17:45:51 +00:00
Sherlock HolmesandGitHub 85d3604309 fix(deps): add missing @opencode-ai/plugin to dependencies (#5797) 2025-12-21 11:45:20 -06:00
Nalin SinghandGitHub 507ba644cf feat: add syntax highlighting for .ets files (#5889) 2025-12-21 11:42:47 -06:00
Adam HoskerandGitHub 3d6f62746a fix: prevent stats workflow from running on forks (#5897) 2025-12-21 11:32:00 -06:00
Abdelkader BoudihandGitHub 2f48c8c05f fix: use official MCP SDK for better tool schema handling (#5463) 2025-12-21 11:31:07 -06:00
GitHub Action 4828fd1eac chore: generate 2025-12-21 14:47:27 +00:00
Matt SilverlockandGitHub 10375263ef github: support schedule events (#5810)
publish-github-action / publish (push) Has been cancelled
Update Nix Hashes / update (push) Has been cancelled
2025-12-21 08:46:54 -06:00
GitHub Action ae00001aa0 ignore: update download stats 2025-12-21 2025-12-21 12:04:17 +00:00
opencode f53ebafbab release: v1.0.184
Update Nix Hashes / update (push) Has been cancelled
2025-12-21 11:04:32 +00:00
Adam 23ebc50da9 fix(desktop): layout regression 2025-12-21 05:01:30 -06:00
Adam 673c6f97b7 fix(desktop): better keybind tooltips 2025-12-21 04:56:20 -06:00
Adam ec46f71258 fix(desktop): todo tool title 2025-12-21 04:41:54 -06:00
Adam 8865e524cb fix(desktop): allow text selection 2025-12-21 04:39:54 -06:00
GitHub Action 36bb02ae45 chore: generate 2025-12-21 10:36:43 +00:00
Adam 5072331f04 fix(desktop): incorrect state dir on macos 2025-12-21 04:36:02 -06:00
opencode 9d48fd4bbd release: v1.0.183
Update Nix Hashes / update (push) Has been cancelled
2025-12-21 10:14:41 +00:00
Adam bf66390557 fix(desktop): better error reporting 2025-12-21 04:11:09 -06:00
Adam 184643f0db fix(desktop): non-latin file paths failed 2025-12-21 04:06:10 -06:00
Adam 1bce898ca7 fix(desktop): file loading errors 2025-12-21 04:02:00 -06:00
Github Action 8c895570c6 Update Nix flake.lock and hashes 2025-12-21 06:11:08 +00:00
Christopher TsoandGitHub 6dc4e5ac93 Make CLI build script Windows-friendly (#5835) 2025-12-21 00:09:58 -06:00
Aiden ClineandGitHub d3922f0965 core: add verification that at least 1 primary agent is enabled, add regression tests (#5881) 2025-12-20 21:36:22 -06:00
GitHub Actionandopencode cfaac9f2e1 chore: generate 2025-12-21 03:07:26 +00:00
opencode 0b046d6cf0 release: v1.0.182
Update Nix Hashes / update (push) Has been cancelled
2025-12-21 03:07:26 +00:00
Aiden Cline 3d822e5f79 fix: regression where config would error despite valid agents 2025-12-20 21:04:37 -06:00
opencode f9cef22a53 release: v1.0.181
Update Nix Hashes / update (push) Has been cancelled
2025-12-21 02:06:25 +00:00
Adam b5d7d3dec1 fix(desktop): layout 2025-12-20 20:02:40 -06:00
Adam 182630e0d7 feat(desktop): new layout 2025-12-20 19:52:12 -06:00
c81506b28d docs: add Traditional Chinese (Taiwan) README translation (#5861)
Co-authored-by: Yu <YuY801103@users.noreply.github.com>
2025-12-20 15:51:42 -06:00
Ryan VogelandGitHub 6c40bfe043 docs: clarify model ID format for OpenCode provider (#5854) 2025-12-20 13:51:13 -06:00
Aiden Cline 9caaae6a18 tweak: better error message if no primary agents are enabled 2025-12-20 13:47:28 -06:00
Ryan VogelandGitHub ad6a5e6157 feat(docs): adding .md to docs pages shows raw markdown (#5823) 2025-12-20 12:05:06 -06:00
7dd8ea58c2 feat: add Catppuccin Frappé theme (#5821)
Co-authored-by: shamil2 <shamil2@users.noreply.github.com>
2025-12-20 12:04:35 -06:00
jaandGitHub 3b261e0125 docs: add name property to model configuration example (#5853) 2025-12-20 11:54:49 -06:00
426791f68a fix: system theme flicker (#5842)
Co-authored-by: Shpetim <shpetim.alimi@ndbit.net>
2025-12-20 11:53:46 -06:00
Frank c7cade2494 zen: sync 2025-12-20 12:52:55 -05:00
8f6c8844d7 feat: support configuring a default_agent across all API/user surfaces (#5843)
publish-github-action / publish (push) Has been cancelled
Update Nix Hashes / update (push) Has been cancelled
Co-authored-by: observerw <observerw@users.noreply.github.com>
2025-12-20 11:49:23 -06:00
Aiden Cline da6e0e60c0 ci: adjust review agent prompt to discourage bad diffs 2025-12-20 11:43:59 -06:00
lifandGitHub d89b567b47 fix: add transform case for gemini if mcp tool has missing array items (#5846) 2025-12-20 11:41:52 -06:00
34eb03f5b8 fix: prioritize session list loading when resuming with -c (#5816)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-12-20 11:39:26 -06:00
Ryan CassidyandGitHub 2f6d15a51e feat: add cursor theme (#5850) 2025-12-20 10:56:27 -06:00
GitHub Actionandopencode 8ffea80980 chore: generate 2025-12-20 16:00:36 +00:00
opencode c87d61b561 release: v1.0.180
Update Nix Hashes / update (push) Has been cancelled
2025-12-20 16:00:36 +00:00
Dax Raad 35c12e2053 ci 2025-12-20 10:54:02 -05:00
opencode 33d8bfc937 release: v1.0.179
Update Nix Hashes / update (push) Has been cancelled
2025-12-20 15:38:38 +00:00
Dax Raad f2343a6794 ci 2025-12-20 10:35:01 -05:00
GitHub Action bab000eeb5 chore: generate 2025-12-20 15:29:23 +00:00
Dax Raad 8e674ae053 ci 2025-12-20 10:28:46 -05:00
opencode 6a4f4009d5 release: v1.0.178
Update Nix Hashes / update (push) Has been cancelled
2025-12-20 15:08:27 +00:00
Dax Raad 5e79b95927 ci 2025-12-20 10:05:03 -05:00
Tommy D. Rossiandopencode a7a2bbb497 feat: add endpoints to delete and update message parts (#5433) 2025-12-20 15:00:41 +00:00
GitHub Actionandopencode 6e93d14bdb chore: generate 2025-12-20 15:00:41 +00:00
opencode f29f284b3e release: v1.0.177
Update Nix Hashes / update (push) Has been cancelled
2025-12-20 15:00:40 +00:00
Dax Raad b1b8f6cf71 ci 2025-12-20 09:57:19 -05:00
opencode 4c3336bbe7 release: v1.0.176
Update Nix Hashes / update (push) Has been cancelled
2025-12-20 12:28:27 +00:00
Adam 354ac0b493 fix(desktop): sidebar UX issues 2025-12-20 06:25:39 -06:00
Adam 1d159c6858 fix(desktop): task rendering perf 2025-12-20 06:15:59 -06:00
GitHub Action d70639b256 chore: generate 2025-12-20 12:13:52 +00:00
Adam e4a92f0084 fix(desktop): show last text part when summarized 2025-12-20 06:12:58 -06:00
Adam fdf5a70a27 fix(desktop): performance with lots of session changes 2025-12-20 06:12:58 -06:00
Adam f71da42520 fix(desktop): event reconnect gaps 2025-12-20 06:12:58 -06:00
GitHub Action f6bdeb9e3a ignore: update download stats 2025-12-20 2025-12-20 12:04:14 +00:00
opencode 2400354bab release: v1.0.175
Update Nix Hashes / update (push) Has been cancelled
2025-12-20 11:10:23 +00:00
Adam db348c46cc fix(desktop): perf tweaks 2025-12-20 05:06:57 -06:00
Adam 49567fe61a fix(desktop): add retries to init promises 2025-12-20 04:57:39 -06:00
Adam e5b3f796e4 fix: types 2025-12-20 04:33:42 -06:00
Adam a9700c8773 fix: shouldEncode 2025-12-20 04:25:17 -06:00
Adam 26cf5e003e fix(desktop): perf stuff 2025-12-20 04:25:17 -06:00
Adam 742cf10dee fix(desktop): removed projects 2025-12-20 04:25:16 -06:00
Frank 7664453f94 zen: add minimax m2.1 2025-12-20 01:55:10 -05:00
GitHub Action 460672aa93 chore: generate 2025-12-20 06:27:02 +00:00
Frank b4e4fd9807 zen: add minimax m2.1 2025-12-20 01:26:27 -05:00
opencode 34bdfd0937 release: v1.0.174
Update Nix Hashes / update (push) Has been cancelled
2025-12-20 04:06:28 +00:00
GitHub Action 84591ca8ad chore: generate 2025-12-20 03:58:36 +00:00
Aiden Cline fd4d0c5c0b fix: file permissions 2025-12-19 21:57:55 -06:00
opencode 9f5db46911 release: v1.0.173
Update Nix Hashes / update (push) Has been cancelled
2025-12-20 03:48:15 +00:00
Ariane EmoryGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
755ddbb223 feat(tui): reinsert forked message text in prompt text input box when forking session (resolves #5495) (#5545)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-19 21:45:33 -06:00
GitHub Action 701d470d01 chore: generate 2025-12-20 03:42:00 +00:00
Aiden Cline 1d9058d26b ci: fix archive 2025-12-19 21:41:18 -06:00
opencode 39e2a5f595 release: v1.0.172
Update Nix Hashes / update (push) Has been cancelled
2025-12-20 03:29:07 +00:00
Aiden Cline f862ab6722 ci: fix undefined 2025-12-19 21:22:49 -06:00
Aiden Cline 129d4f0b1b ci: fix release notes
Update Nix Hashes / update (push) Has been cancelled
2025-12-19 17:49:07 -06:00
Aiden Cline 3a1e50d1f8 Revert "tweak: better release notes (grouped changelog) (#5768)"
This reverts commit b99afdad91.
2025-12-19 17:46:38 -06:00
Aiden Cline e2fb690d8e Revert "tweak: DevEx to run changelog independently (#5774)"
This reverts commit 7f8e799392.
2025-12-19 17:46:36 -06:00
Aiden Cline 0a7f58a811 Revert "ci: gemini 3 flash doesnt exist in pinned cicd version (#5776)"
This reverts commit 382905602c.
2025-12-19 17:46:35 -06:00
Aiden Cline dae0168ed8 test: fixture cleanup 2025-12-19 17:00:23 -06:00
Aiden Cline edfe2e4f1c test: fix test case 2025-12-19 16:37:36 -06:00
KasparandGitHub 1bc1ea8b47 tweak: Make LSP message more accurate when LSPs disabled (#5814) 2025-12-19 16:34:04 -06:00
Aiden Cline dacbbe3184 chore: rm dead code 2025-12-19 16:20:17 -06:00
Aiden Cline 89285d8f5f ci: fix publish auth failure 2025-12-19 16:00:41 -06:00
1XDandGitHub 2e853911c3 docs: replace deprecated mise ubi backend with github backend (#5811) 2025-12-19 15:51:09 -06:00
Github Action 695fdecf23 Update Nix flake.lock and hashes 2025-12-19 20:40:07 +00:00
Frank 054d22791d zen: sync 2025-12-19 15:38:31 -05:00
Frank 4a57cc69d8 zen: sync 2025-12-19 15:25:35 -05:00
Frank 7e0c8db029 zen: sync 2025-12-19 15:21:57 -05:00
CameronandGitHub ba4cc3bf86 Desktop file encoding issue (#5490) 2025-12-19 14:18:37 -06:00
Adam b19a424c85 chore: cleanup 2025-12-19 13:11:08 -06:00
Adam 1689281c35 fix(desktop): auto-scroll and session perf 2025-12-19 13:07:53 -06:00
Adam cdbb59fae8 fix(desktop): don't use tauri http for sse events 2025-12-19 13:07:53 -06:00
Adam 4eb311e98f fix(desktop): error height 2025-12-19 13:07:52 -06:00
Github Action 80eac96258 Update Nix flake.lock and hashes 2025-12-19 18:29:37 +00:00
Aiden Cline 4bad6f9f1b tweak: use fetch instead of octokit for now 2025-12-19 12:28:11 -06:00
Steven T. CramerandGitHub d7db57e8e1 docs: add Windows Terminal Shift+Enter configuration guide (#5788) 2025-12-19 12:27:21 -06:00
943fbf39a3 ci: separate standalone publishing from dependent publishing (#5634)
Co-authored-by: GitHub Action <action@github.com>
2025-12-19 13:22:17 -05:00
Dax Raad d8a34c2fcc core: prevent file system scanning when in root directory to avoid unnecessary operations 2025-12-19 13:15:11 -05:00
Aiden Cline 5720ed1f44 ci: change token for gh release to allow discord job to actually trigger see: https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/trigger-a-workflow 2025-12-19 10:45:26 -06:00
GitHub Action bb20a359e4 chore: generate 2025-12-19 14:56:47 +00:00
José ValimandGitHub 0d472a49a0 Do not include hidden agents in ACP (#5791) 2025-12-19 08:56:12 -06:00
Github Actionandopencode 203581e82f Update Nix flake.lock and hashes 2025-12-19 13:42:49 +00:00
Sebastian Herrlingerandopencode 677631916c upgrade opentui to v0.1.62, enabling textarea mouse scroll and cursor set 2025-12-19 13:42:49 +00:00
Github Actionandopencode 1aa1e8c904 Update Nix flake.lock and hashes 2025-12-19 13:42:49 +00:00
opencode 55d62fbd9f release: v1.0.170
Update Nix Hashes / update (push) Has been cancelled
2025-12-19 13:42:48 +00:00
Adam e1ad2a355c fix(desktop): error handling 2025-12-19 07:38:38 -06:00
Adam 4f318f913e chore: logging 2025-12-19 07:38:38 -06:00
Adam 2d814b6db2 fix(desktop): separate prompt history for shell 2025-12-19 07:38:38 -06:00
Adam e561f1ad68 fix(desktop): don't navigate prompt history if dirty 2025-12-19 07:38:37 -06:00
Sebastian Herrlinger ebfb985215 user messages as markdown with toggle 2025-12-19 13:51:26 +01:00
GitHub Action 2646da50df ignore: update download stats 2025-12-19 2025-12-19 12:04:33 +00:00
Github Action 50a5f6e53b Update Nix flake.lock and hashes 2025-12-19 11:49:40 +00:00
d03fac52e7 Update SolidStart and bring back HttpHeader usage (#5355)
Co-authored-by: Github Action <action@github.com>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2025-12-19 05:46:33 -06:00
6a802c01cd feat(tui): implement smooth scrolling for autocomplete dropdown navigation (#5559)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-12-19 00:40:16 -06:00
Eric ShirleyandGitHub 14146428dd lsp: add oxlint server (#5570) 2025-12-19 00:17:20 -06:00
Aiden Cline 26d0280f70 docs: contributing 2025-12-18 22:18:34 -06:00
Aiden Cline 3274a5813e ci: only run generate for dev 2025-12-18 22:17:36 -06:00
Luke ParkerandGitHub 382905602c ci: gemini 3 flash doesnt exist in pinned cicd version (#5776) 2025-12-18 21:59:46 -06:00
GitHub Action 8b5cea7899 chore: generate 2025-12-19 03:59:14 +00:00
Matt SilverlockandGitHub 100c31cbb1 fix: use correct octokit API for PR review comment reactions (#5778) 2025-12-18 21:58:41 -06:00
GitHub Actionandopencode 0b286f1b84 chore: generate 2025-12-19 02:12:35 +00:00
Brendan Allanandopencode 2f6ca958fe tauri: remove pinch-to-zoom on window 2025-12-19 02:12:35 +00:00
Basit Mustafaandopencode 5218e7a546 docs(ecosystem): add opencode-zellij-namer plugin (#5771) 2025-12-19 02:12:35 +00:00
Luke Parkerandopencode 7f8e799392 tweak: DevEx to run changelog independently (#5774) 2025-12-19 02:12:35 +00:00
opencode 289f4abaaa release: v1.0.169
Update Nix Hashes / update (push) Has been cancelled
2025-12-19 02:12:34 +00:00
Adam 7ce898ce43 fix(desktop): shell mode 2025-12-18 20:06:53 -06:00
Adam 0dd716a75e fix(desktop): extra reqs 2025-12-18 19:53:38 -06:00
Aiden Cline 87171467fa ci: better err msg for generate workflow 2025-12-18 19:03:16 -06:00
Luke ParkerandGitHub b99afdad91 tweak: better release notes (grouped changelog) (#5768) 2025-12-18 18:49:37 -06:00
Aiden Cline 4fd576f3af fix: better api call error msgs in some cases 2025-12-18 18:46:25 -06:00
GitHub Action 2f41d0bedd chore: generate 2025-12-19 00:18:07 +00:00
5f03290534 feat(tui): click on subagents to open them (#5761)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-12-18 18:17:34 -06:00
opencode 427157c683 release: v1.0.168
Update Nix Hashes / update (push) Has been cancelled
2025-12-18 21:55:29 +00:00
Github Action a0ab3d98b7 Update Nix flake.lock and hashes 2025-12-18 21:48:53 +00:00
GitHub Action c8de766913 chore: generate 2025-12-18 21:47:59 +00:00
Adam d57b963141 fix: id 2025-12-18 15:47:21 -06:00
Adam 0ebcaff927 fix(desktop): expanded states 2025-12-18 15:47:20 -06:00
Adam 15931fa170 chore: cleanup 2025-12-18 15:47:20 -06:00
Adam af4087d7b5 fix(desktop): smaller max-width when review open 2025-12-18 15:47:20 -06:00
Aiden Cline 323ea1040c ci: fix generate 2025-12-18 15:23:23 -06:00
Aiden Cline 1fe87b0233 ci: fix file perm 2025-12-18 14:39:44 -06:00
Aiden Cline 8d11df1b3b ci: handle case where generate.yml fails better 2025-12-18 14:33:40 -06:00
Aiden Cline ecc5050838 tweak: more retry cases 2025-12-18 13:59:37 -06:00
Aiden Cline 606cf3b6f2 chore: rm dead code 2025-12-18 13:59:37 -06:00
GitHub Action 67cfd7f06b chore: format code 2025-12-18 19:38:25 +00:00
OpeOginniandGitHub ab9ac7c87a feat: add experimental support for Ty language server (#5575) 2025-12-18 13:37:48 -06:00
Adam ee9f979613 fix(desktop): markdown styles 2025-12-18 13:03:14 -06:00
Adam 228b6444f8 fix(desktop): don't show image button in shell mode 2025-12-18 13:03:14 -06:00
Frank 9998efdae2 zen: cleanup headers 2025-12-18 13:47:31 -05:00
Aiden Cline 9427f56e1a rm interleaved thinking filter for certain kimi k2 thinking model providers that were bugged 2025-12-18 12:26:27 -06:00
Adam a6dd35d73d fix(desktop): submit prompt 2025-12-18 12:03:21 -06:00
GitHub Action faeaafa5f5 chore: format code 2025-12-18 17:31:49 +00:00
Matt SilverlockandGitHub 8b298a233e github: add OIDC_BASE_URL for custom GitHub App installs (#5756)
publish-github-action / publish (push) Has been cancelled
Update Nix Hashes / update (push) Has been cancelled
2025-12-18 11:31:13 -06:00
Adam 6f43d03043 fix(desktop): checkbox render in safari fml 2025-12-18 11:16:33 -06:00
Adam c868a4088d fix(desktop): rendering shell mode messages 2025-12-18 11:16:33 -06:00
Adam 83d8a88c90 fix(desktop): error styles 2025-12-18 11:16:33 -06:00
Adam 268f37f8c9 fix(desktop): prompt history nav, optimistic prompt dup 2025-12-18 11:16:33 -06:00
Adam b0aaf04957 fix(desktop): session ordered by most recent 2025-12-18 11:16:32 -06:00
Adam b7875256f3 feat(desktop): shell mode 2025-12-18 11:16:32 -06:00
Adam 7bc47fb904 chore: cleanup 2025-12-18 11:16:32 -06:00
GitHub Action 5cf8e54372 chore: format code 2025-12-18 16:39:21 +00:00
Ariane EmoryGitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
7437ccd6f4 feat(tui): fork slash command for keyboard-friendly session forking (resolves #5599) (#5610)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-18 10:38:19 -06:00
Jeon SuyeolandGitHub 4bf882ba81 fix(command): validate model before executing slash command (#5740) 2025-12-18 10:35:40 -06:00
Frank d5dcc55a47 Revert "add client header"
This reverts commit 2fb89161c8.
2025-12-18 11:21:22 -05:00
barışandGitHub e1925f4fe8 docs: fix typos (#5753)
publish-github-action / publish (push) Has been cancelled
Update Nix Hashes / update (push) Has been cancelled
2025-12-18 09:56:37 -06:00
Aiden Cline ee3d034e16 ci: fix discord 2025-12-18 09:56:08 -06:00
Aiden Cline 257a4d5b86 bump bun version 2025-12-18 09:47:42 -06:00
Daniel PolitoandGitHub 1fc5836f64 Improve Github Action Hallucinations (#5751) 2025-12-18 09:40:04 -06:00
Frank 2fb89161c8 add client header 2025-12-18 10:33:25 -05:00
GitHub Action 251fbc0a99 ignore: update download stats 2025-12-18 2025-12-18 12:16:54 +00:00
Brendan AllanandGitHub 0da901a188 tauri: disable pinch zoom on linux (#5735) 2025-12-18 05:13:09 -06:00
Brendan AllanandGitHub 17221e6ffe tauri: configure display backends more correctly on linux (#5730) 2025-12-18 18:34:12 +08:00
GitHub Action cc9f88ac8f chore: format code 2025-12-18 10:28:55 +00:00
Adam fe65ed6a61 fix(desktop): disable pinch to zoom 2025-12-18 04:28:03 -06:00
Adam e37a75a411 feat(desktop): custom update toast 2025-12-18 04:26:21 -06:00
opencode 194ff4919c release: v1.0.167
Update Nix Hashes / update (push) Has been cancelled
2025-12-18 09:45:33 +00:00
shuvandGitHub 83843a794f fix: handle empty directory query parameter in server middleware (#5732) 2025-12-18 03:27:50 -06:00
Brendan Allan 235a60d3c2 tauri: say OpenCode Server instead of OpenCode CLI 2025-12-18 17:18:46 +08:00
Brendan AllanandGitHub b70d186bd1 tauri: server spawn fail dialog w/ copy logs button (#5729) 2025-12-18 03:17:31 -06:00
Frank 647331de28 zen: error handling for stream requests 2025-12-18 00:47:37 -05:00
GitHub Action 57ef115375 chore: format code 2025-12-18 04:30:54 +00:00
Jeon SuyeolandGitHub 942498211f docs: add OPENCODE_DISABLE_TERMINAL_TITLE to environment variables (#5725) 2025-12-17 22:30:21 -06:00
Jake NelsonandGitHub e789fcf5e5 feat(tui): add option to disable terminal title (#5713) 2025-12-17 22:30:01 -06:00
Frank b9fb180bc6 zen: error handling for stream requests 2025-12-17 22:55:44 -05:00
Rohan MukherjeeandGitHub 7427b887f9 MCP improvements (#5699) 2025-12-17 20:49:45 -06:00
Jay V 289b2b6a51 docs: add legal pages with privacy policy and terms of service links 2025-12-17 20:17:33 -05:00
GitHub Action 49b4b5907e chore: format code 2025-12-18 01:09:02 +00:00
Ryan VogelandGitHub f82442c123 docs: add opencode.cafe to ecosystem page (#5714) 2025-12-17 19:08:29 -06:00
opencode e682cc9daf release: v1.0.166
Update Nix Hashes / update (push) Has been cancelled
2025-12-17 22:08:15 +00:00
Adam d359e086a4 chore: cleanup 2025-12-17 16:04:41 -06:00
Adam f949755367 fix: better init error messages 2025-12-17 16:04:40 -06:00
Adam a168d854f4 fix: auto-scroll 2025-12-17 16:04:40 -06:00
GitHub Action 31645f5578 chore: format code 2025-12-17 22:03:43 +00:00
Sercan SagmanandGitHub a1b68daa9a fix(tui): exclude reverted assistant reply when copying last message (#5705)
Signed-off-by: assagman <ahmetsercansagman@gmail.com>
2025-12-17 16:03:06 -06:00
opencode ca65da2d9e release: v1.0.165
Update Nix Hashes / update (push) Has been cancelled
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 SinghandGitHub 4d7c3f56fa feat: add viewportOptions to scrollbox for padding adjustments to avoid scrollbar overlap (#5703) 2025-12-17 15:09:41 -06:00
16b41d2bea UI: show plugins in /status (#4515)
Co-authored-by: GitHub Action <action@github.com>
2025-12-17 15:01:52 -06:00
Nalin SinghandGitHub a8c499ae8f fix: prevent session list selection from jumping to active session when confirming delete (#5666) 2025-12-17 14:35:46 -06:00
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
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 KumarandGitHub 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
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 RicciutiandGitHub 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
SpoonandGitHub 8864da7a77 batch: enable edit, todoread, clarify error message, minor tool description change (#5659) 2025-12-17 10:23:35 -06:00
Rhys SullivanandGitHub 1b39199083 fix: change subagent navigation order to newest-to-oldest (#5680) 2025-12-17 10:22:57 -06:00
Shantur RathoreandGitHub 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
StoufilerandGitHub 6454adcd69 docs: Sort LSP Server list (#5688) 2025-12-17 09:14:26 -06:00
Rohan MukherjeeGitHubopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-node
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
SachnunandGitHub 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
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
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 SuyeolandGitHub 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
SpoonandGitHub 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 SilverlockandGitHub 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
Update Nix Hashes / update (push) Has been cancelled
2025-12-16 21:47:41 +00:00
Adam f07d4b933c fix(desktop): prompt history nav 2025-12-16 15:42:35 -06:00
Aiden ClineandGitHub 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
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
matveyandGitHub 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
publish-github-action / publish (push) Has been cancelled
Update Nix Hashes / update (push) Has been cancelled
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 ZekanandGitHub 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
shekohexandGitHub b78e2db013 docs: fix typo in Google Antigravity github link (#5625) 2025-12-16 11:29:31 -06:00
jinzhongjiaandGitHub 3f4d1121a4 docs: Add new project entry for opencode.nvim frontend (#5626) 2025-12-16 11:29:06 -06:00
Connor AdamsGitHubopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-nodeAiden Cline
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
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
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'MoriasandGitHub 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 ClineandGitHub 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 ParkerandGitHub ef78fd8bae fix: debounce LSP diagnostics to get complete results (#5600) 2025-12-15 21:26:59 -06:00
DSAiden Clinegithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
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 DuailibeandGitHub 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
Update Nix Hashes / update (push) Has been cancelled
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
Update Nix Hashes / update (push) Has been cancelled
2025-12-16 00:17:15 +00:00
Adam 2c70c0b00f fix: undefined events 2025-12-15 18:13:11 -06:00
opencode-agent[bot]GitHubopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-node
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 ParkerandGitHub 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 ParkerandGitHub 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
Update Nix Hashes / update (push) Has been cancelled
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 ParkerandGitHub 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 ParkerandGitHub 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
Update Nix Hashes / update (push) Has been cancelled
2025-12-15 22:19:08 +00:00
Dax Raad 56452d886d fix dialog root complexity 2025-12-15 17:13:20 -05:00
Luke ParkerandGitHub f3e64cfb19 fix(windows): opencode github install (#5587) 2025-12-15 16:12:54 -06:00
Ariane EmoryandGitHub 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]GitHubopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-nodeAiden Cline
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 EmoryandGitHub 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
Update Nix Hashes / update (push) Has been cancelled
2025-12-15 20:28:44 +00:00
Adam 02b5e7d72c fix: modal search 2025-12-15 14:25:19 -06:00
justfortheloveofandGitHub 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
528 changed files with 16484 additions and 5292 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
});
}
+69
View File
@@ -0,0 +1,69 @@
name: Docs Update
on:
schedule:
- cron: "0 */12 * * *"
workflow_dispatch:
jobs:
update-docs:
if: github.repository == 'sst/opencode'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
id-token: write
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch full history to access commits
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Get recent commits
id: commits
run: |
COMMITS=$(git log --since="4 hours ago" --pretty=format:"- %h %s" 2>/dev/null || echo "")
if [ -z "$COMMITS" ]; then
echo "No commits in the last 4 hours"
echo "has_commits=false" >> $GITHUB_OUTPUT
else
echo "has_commits=true" >> $GITHUB_OUTPUT
{
echo "list<<EOF"
echo "$COMMITS"
echo "EOF"
} >> $GITHUB_OUTPUT
fi
- name: Run opencode
if: steps.commits.outputs.has_commits == 'true'
uses: sst/opencode/github@latest
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
with:
model: opencode/gpt-5.2
agent: docs
prompt: |
Review the following commits from the last 4 hours and identify any new features that may need documentation.
<recent_commits>
${{ steps.commits.outputs.list }}
</recent_commits>
Steps:
1. For each commit that looks like a new feature or significant change:
- Read the changed files to understand what was added
- Check if the feature is already documented in packages/web/src/content/docs/*
2. If you find undocumented features:
- Update the relevant documentation files in packages/web/src/content/docs/*
- Follow the existing documentation style and structure
- Make sure to document the feature clearly with examples where appropriate
3. If all new features are already documented, report that no updates are needed
4. If you are creating a new documentation file be sure to update packages/web/astro.config.mjs too.
Focus on user-facing features and API changes. Skip internal refactors, bug fixes, and test updates unless they affect user-facing behavior.
Don't feel the need to document every little thing. It is perfectly okay to make 0 changes at all.
Try to keep documentation only for large features or changes that already have a good spot to be documented.
+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
+28 -15
View File
@@ -2,11 +2,8 @@ name: generate
on:
push:
branches-ignore:
- production
pull_request:
branches-ignore:
- production
branches:
- dev
workflow_dispatch:
jobs:
@@ -14,6 +11,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -25,14 +23,29 @@ jobs:
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Generate SDK
run: |
bun ./packages/sdk/js/script/build.ts
(cd packages/opencode && bun dev generate > ../sdk/openapi.json)
bun x prettier --write packages/sdk/openapi.json
- name: Generate
run: ./script/generate.ts
- name: Format
run: ./script/format.ts
env:
CI: true
PUSH_BRANCH: ${{ github.event.pull_request.head.ref || github.ref_name }}
- name: Commit and push
run: |
if [ -z "$(git status --porcelain)" ]; then
echo "No changes to commit"
exit 0
fi
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add -A
git commit -m "chore: generate"
git push origin HEAD:${{ github.ref_name }} --no-verify
# if ! git push origin HEAD:${{ github.event.pull_request.head.ref || github.ref_name }} --no-verify; then
# echo ""
# echo "============================================"
# echo "Failed to push generated code."
# echo "Please run locally and push:"
# echo ""
# echo " ./script/generate.ts"
# echo " git add -A && git commit -m \"chore: generate\" && git push"
# echo ""
# echo "============================================"
# exit 1
# fi
+1 -1
View File
@@ -2,7 +2,7 @@ name: discord
on:
release:
types: [published] # fires only when a release is published
types: [released] # fires when a draft release is published
jobs:
notify:
+60 -26
View File
@@ -41,21 +41,9 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Setup SSH for AUR
if: inputs.bump || inputs.version
run: |
sudo apt-get update
sudo apt-get install -y pacman-package-manager
mkdir -p ~/.ssh
echo "${{ secrets.AUR_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
git config --global user.email "opencode@sst.dev"
git config --global user.name "opencode"
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts || true
- name: Install OpenCode
if: inputs.bump || inputs.version
run: bun i -g opencode-ai@1.0.143
run: bun i -g opencode-ai@1.0.169
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
@@ -75,9 +63,15 @@ jobs:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Setup Git Identity
run: |
git config --global user.email "opencode@sst.dev"
git config --global user.name "opencode"
git remote set-url origin https://x-access-token:${{ secrets.SST_GITHUB_TOKEN }}@github.com/${{ github.repository }}
- name: Publish
id: publish
run: ./script/publish.ts
run: ./script/publish-start.ts
env:
OPENCODE_BUMP: ${{ inputs.bump }}
OPENCODE_VERSION: ${{ inputs.version }}
@@ -85,9 +79,16 @@ jobs:
AUR_KEY: ${{ secrets.AUR_KEY }}
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
NPM_CONFIG_PROVENANCE: false
- uses: actions/upload-artifact@v4
with:
name: opencode-cli
path: packages/opencode/dist
outputs:
releaseId: ${{ steps.publish.outputs.releaseId }}
tagName: ${{ steps.publish.outputs.tagName }}
release: ${{ steps.publish.outputs.release }}
tag: ${{ steps.publish.outputs.tag }}
version: ${{ steps.publish.outputs.version }}
publish-tauri:
needs: publish
@@ -104,12 +105,14 @@ jobs:
target: x86_64-pc-windows-msvc
- host: blacksmith-4vcpu-ubuntu-2404
target: x86_64-unknown-linux-gnu
- host: blacksmith-4vcpu-ubuntu-2404-arm
target: aarch64-unknown-linux-gnu
runs-on: ${{ matrix.settings.host }}
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
ref: ${{ needs.publish.outputs.tagName }}
ref: ${{ needs.publish.outputs.tag }}
- uses: apple-actions/import-codesign-certs@v2
if: ${{ runner.os == 'macOS' }}
@@ -148,23 +151,22 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
workspaces: packages/tauri/src-tauri
workspaces: packages/desktop/src-tauri
shared-key: ${{ matrix.settings.target }}
- name: Prepare
run: |
cd packages/tauri
cd packages/desktop
bun ./scripts/prepare.ts
env:
OPENCODE_BUMP: ${{ inputs.bump }}
OPENCODE_VERSION: ${{ inputs.version }}
OPENCODE_CHANNEL: latest
OPENCODE_VERSION: ${{ needs.publish.outputs.version }}
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
AUR_KEY: ${{ secrets.AUR_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
- name: Install tauri-cli from portable appimage branch
@@ -189,11 +191,43 @@ jobs:
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
with:
projectPath: packages/tauri
projectPath: packages/desktop
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 --verbose
updaterJsonPreferNsis: true
releaseId: ${{ needs.publish.outputs.releaseId }}
tagName: ${{ needs.publish.outputs.tagName }}
releaseId: ${{ needs.publish.outputs.release }}
tagName: ${{ needs.publish.outputs.tag }}
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
releaseDraft: true
publish-release:
needs:
- publish
- publish-tauri
if: needs.publish.outputs.tag
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
ref: ${{ needs.publish.outputs.tag }}
- uses: ./.github/actions/setup-bun
- name: Setup SSH for AUR
run: |
sudo apt-get update
sudo apt-get install -y pacman-package-manager
mkdir -p ~/.ssh
echo "${{ secrets.AUR_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
git config --global user.email "opencode@sst.dev"
git config --global user.name "opencode"
ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts || true
- run: ./script/publish-complete.ts
env:
OPENCODE_VERSION: ${{ needs.publish.outputs.version }}
AUR_KEY: ${{ secrets.AUR_KEY }}
GITHUB_TOKEN: ${{ secrets.SST_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
+4
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
@@ -65,6 +67,8 @@ jobs:
When critiquing code style don't be a zealot, we don't like "let" statements but sometimes they are the simpliest option, if someone does a bunch of nesting with let, they should consider using iife (see packages/opencode/src/util.iife.ts)
Use the gh cli to create comments on the files for the violations. Try to leave the comment on the exact line number. If you have a suggested fix include it in a suggestion code block.
If you are writing suggested fixes, BE SURE THAT the change you are recommending is actually valid typescript, often I have seen missing closing "}" or other syntax errors.
Generally, write a comment instead of writing suggested change if you can help it.
Command MUST be like this.
\`\`\`
+3
View File
@@ -5,8 +5,11 @@ on:
- cron: "0 12 * * *" # Run daily at 12:00 UTC
workflow_dispatch: # Allow manual trigger
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
stats:
if: github.repository == 'sst/opencode'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
+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"
+1
View File
@@ -19,3 +19,4 @@ Session.vim
opencode.json
a.out
target
.scripts
+77
View File
@@ -0,0 +1,77 @@
---
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 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
}
+3
View File
@@ -11,4 +11,7 @@
},
},
"mcp": {},
"tools": {
"github-triage": false,
},
}
+6
View File
@@ -0,0 +1,6 @@
---
name: test-skill
description: use this when asked to test skill
---
woah this is a test skill
+90
View File
@@ -0,0 +1,90 @@
/// <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
}
async function githubFetch(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, {
...options,
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
Accept: "application/vnd.github+json",
"Content-Type": "application/json",
...options.headers,
},
})
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
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],
// })
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, {
method: "POST",
body: JSON.stringify({ 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,
// })
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, {
method: "POST",
body: JSON.stringify({ 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
+1 -28
View File
@@ -4,31 +4,4 @@
## Tool Calling
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. Here is an example illustrating how to execute 3 parallel file reads in this chat environment:
json
{
"recipient_name": "multi_tool_use.parallel",
"parameters": {
"tool_uses": [
{
"recipient_name": "functions.read",
"parameters": {
"filePath": "path/to/file.tsx"
}
},
{
"recipient_name": "functions.read",
"parameters": {
"filePath": "path/to/file.ts"
}
},
{
"recipient_name": "functions.read",
"parameters": {
"filePath": "path/to/file.md"
}
}
]
}
}
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
+1 -1
View File
@@ -40,7 +40,7 @@ Want to take on an issue? Leave a comment and a maintainer may assign it to you
- `packages/plugin`: Source for `@opencode-ai/plugin`
> [!NOTE]
> After touching `packages/opencode/src/server/server.ts`, run "./packages/sdk/js/script/build.ts" to regenerate the JS sdk.
> If you make changes to the API or SDK (e.g. `packages/opencode/src/server/server.ts`), run `./script/generate.ts` to regenerate the SDK and related files.
Please try to follow the [style guide](./STYLE_GUIDE.md)
+18 -2
View File
@@ -30,13 +30,29 @@ scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows
brew install opencode # macOS and Linux
paru -S opencode-bin # Arch Linux
mise use -g ubi:sst/opencode # Any OS
mise use -g github:sst/opencode # Any OS
nix run nixpkgs#opencode # or github:sst/opencode for latest dev branch
```
> [!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:
@@ -78,7 +94,7 @@ If you're interested in contributing to OpenCode, please read our [contributing
### Building on OpenCode
If you are working on a project that's related to OpenCode and is using "opencode" as a part of its name; for example, "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in anyway.
If you are working on a project that's related to OpenCode and is using "opencode" as a part of its name; for example, "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in any way.
### FAQ
+115
View File
@@ -0,0 +1,115 @@
<p align="center">
<a href="https://opencode.ai">
<picture>
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
</picture>
</a>
</p>
<p align="center">開源的 AI Coding Agent。</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/sst/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/sst/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
---
### 安裝
```bash
# 直接安裝 (YOLO)
curl -fsSL https://opencode.ai/install | bash
# 套件管理員
npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows
brew install opencode # macOS 與 Linux
paru -S opencode-bin # Arch Linux
mise use -g github:sst/opencode # 任何作業系統
nix run nixpkgs#opencode # 或使用 github:sst/opencode 以取得最新開發分支
```
> [!TIP]
> 安裝前請先移除 0.1.x 以前的舊版本。
### 桌面應用程式 (BETA)
OpenCode 也提供桌面版應用程式。您可以直接從 [發佈頁面 (releases page)](https://github.com/sst/opencode/releases) 或 [opencode.ai/download](https://opencode.ai/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`, 或 AppImage |
```bash
# macOS (Homebrew Cask)
brew install --cask opencode-desktop
```
#### 安裝目錄
安裝腳本會依據以下優先順序決定安裝路徑:
1. `$OPENCODE_INSTALL_DIR` - 自定義安裝目錄
2. `$XDG_BIN_DIR` - 符合 XDG 基礎目錄規範的路徑
3. `$HOME/bin` - 標準使用者執行檔目錄 (若存在或可建立)
4. `$HOME/.opencode/bin` - 預設備用路徑
```bash
# 範例
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
```
### Agents
OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
- **build** - 預設模式,具備完整權限的 Agent,適用於開發工作。
- **plan** - 唯讀模式,適用於程式碼分析與探索。
- 預設禁止修改檔案。
- 執行 bash 指令前會詢問權限。
- 非常適合用來探索陌生的程式碼庫或規劃變更。
此外,OpenCode 還包含一個 **general** 子 Agent,用於處理複雜搜尋與多步驟任務。此 Agent 供系統內部使用,亦可透過在訊息中輸入 `@general` 來呼叫。
了解更多關於 [Agents](https://opencode.ai/docs/agents) 的資訊。
### 線上文件
關於如何設定 OpenCode 的詳細資訊,請參閱我們的 [**官方文件**](https://opencode.ai/docs)。
### 參與貢獻
如果您有興趣參與 OpenCode 的開發,請在提交 Pull Request 前先閱讀我們的 [貢獻指南 (Contributing Docs)](./CONTRIBUTING.md)。
### 基於 OpenCode 進行開發
如果您正在開發與 OpenCode 相關的專案,並在名稱中使用了 "opencode"(例如 "opencode-dashboard" 或 "opencode-mobile"),請在您的 README 中加入聲明,說明該專案並非由 OpenCode 團隊開發,且與我們沒有任何隸屬關係。
### 常見問題 (FAQ)
#### 這跟 Claude Code 有什麼不同?
在功能面上與 Claude Code 非常相似。以下是關鍵差異:
- 100% 開源。
- 不綁定特定的服務提供商。雖然我們推薦使用透過 [OpenCode Zen](https://opencode.ai/zen) 提供的模型,但 OpenCode 也可搭配 Claude, OpenAI, Google 甚至本地模型使用。隨著模型不斷演進,彼此間的差距會縮小且價格會下降,因此具備「不限廠商 (provider-agnostic)」的特性至關重要。
- 內建 LSP (語言伺服器協定) 支援。
- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造;我們將不斷挑戰終端機介面的極限。
- 客戶端/伺服器架構 (Client/Server Architecture)。這讓 OpenCode 能夠在您的電腦上運行的同時,由行動裝置進行遠端操控。這意味著 TUI 前端只是眾多可能的客戶端之一。
#### 另一個同名的 Repo 是什麼?
另一個名稱相近的儲存庫與本專案無關。您可以點此[閱讀背後的故事](https://x.com/thdxr/status/1933561254481666466)。
---
**加入我們的社群** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+8
View File
@@ -171,3 +171,11 @@
| 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) |
| 2025-12-18 | 1,178,658 (+27,591) | 1,113,418 (+15,757) | 2,292,076 (+43,348) |
| 2025-12-19 | 1,203,485 (+24,827) | 1,129,698 (+16,280) | 2,333,183 (+41,107) |
| 2025-12-20 | 1,223,000 (+19,515) | 1,146,258 (+16,560) | 2,369,258 (+36,075) |
| 2025-12-21 | 1,242,675 (+19,675) | 1,158,909 (+12,651) | 2,401,584 (+32,326) |
| 2025-12-22 | 1,262,522 (+19,847) | 1,169,121 (+10,212) | 2,431,643 (+30,059) |
| 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) |
+354 -329
View File
File diff suppressed because it is too large Load Diff
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1765772535,
"narHash": "sha256-aq+dQoaPONOSjtFIBnAXseDm9TUhIbe215TPmkfMYww=",
"lastModified": 1766314097,
"narHash": "sha256-laJftWbghBehazn/zxVJ8NdENVgjccsWAdAqKXhErrM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "09b8fda8959d761445f12b55f380d90375a1d6bb",
"rev": "306ea70f9eb0fb4e040f8540e2deab32ed7e2055",
"type": "github"
},
"original": {
+2 -2
View File
@@ -6,7 +6,7 @@ Mention `/opencode` in your comment, and opencode will execute tasks within your
## Features
#### Explain an issues
#### Explain an issue
Leave the following comment on a GitHub issue. `opencode` will read the entire thread, including all comments, and reply with a clear explanation.
@@ -14,7 +14,7 @@ Leave the following comment on a GitHub issue. `opencode` will read the entire t
/opencode explain this issue
```
#### Fix an issues
#### Fix an issue
Leave the following comment on a GitHub issue. opencode will create a new branch, implement the changes, and open a PR with the changes.
+15
View File
@@ -9,6 +9,10 @@ inputs:
description: "Model to use"
required: true
agent:
description: "Agent to use. Must be a primary agent. Falls back to default_agent from config or 'build' if not found."
required: false
share:
description: "Share the opencode session (defaults to true for public repos)"
required: false
@@ -22,6 +26,14 @@ inputs:
required: false
default: "false"
mentions:
description: "Comma-separated list of trigger phrases (case-insensitive). Defaults to '/opencode,/oc'"
required: false
oidc_base_url:
description: "Base URL for OIDC token exchange API. Only required when running a custom GitHub App install. Defaults to https://api.opencode.ai"
required: false
runs:
using: "composite"
steps:
@@ -54,6 +66,9 @@ runs:
run: opencode github run
env:
MODEL: ${{ inputs.model }}
AGENT: ${{ inputs.agent }}
SHARE: ${{ inputs.share }}
PROMPT: ${{ inputs.prompt }}
USE_GITHUB_TOKEN: ${{ inputs.use_github_token }}
MENTIONS: ${{ inputs.mentions }}
OIDC_BASE_URL: ${{ inputs.oidc_base_url }}
+31 -2
View File
@@ -318,6 +318,10 @@ function useEnvRunUrl() {
return `/${repo.owner}/${repo.repo}/actions/runs/${runId}`
}
function useEnvAgent() {
return process.env["AGENT"] || undefined
}
function useEnvShare() {
const value = process.env["SHARE"]
if (!value) return undefined
@@ -570,24 +574,49 @@ async function subscribeSessionEvents() {
}
async function summarize(response: string) {
const payload = useContext().payload as IssueCommentEvent
try {
return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
} catch (e) {
if (isScheduleEvent()) {
return "Scheduled task changes"
}
const payload = useContext().payload as IssueCommentEvent
return `Fix issue: ${payload.issue.title}`
}
}
async function resolveAgent(): Promise<string | undefined> {
const envAgent = useEnvAgent()
if (!envAgent) return undefined
// Validate the agent exists and is a primary agent
const agents = await client.agent.list<true>()
const agent = agents.data?.find((a) => a.name === envAgent)
if (!agent) {
console.warn(`agent "${envAgent}" not found. Falling back to default agent`)
return undefined
}
if (agent.mode === "subagent") {
console.warn(`agent "${envAgent}" is a subagent, not a primary agent. Falling back to default agent`)
return undefined
}
return envAgent
}
async function chat(text: string, files: PromptFiles = []) {
console.log("Sending message to opencode...")
const { providerID, modelID } = useEnvModel()
const agent = await resolveAgent()
const chat = await client.session.chat<true>({
path: session,
body: {
providerID,
modelID,
agent: "build",
agent,
parts: [
{
type: "text",
+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
View File
@@ -44,3 +44,12 @@ new sst.cloudflare.x.Astro("Web", {
VITE_API_URL: api.url.apply((url) => url!),
},
})
new sst.cloudflare.StaticSite("WebApp", {
domain: "app." + domain,
path: "packages/app",
build: {
command: "bun turbo build",
output: "./dist",
},
})
+2
View File
@@ -118,6 +118,7 @@ const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
////////////////
const bucket = new sst.cloudflare.Bucket("ZenData")
const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
@@ -136,6 +137,7 @@ new sst.cloudflare.x.SolidStart("Console", {
path: "packages/console/app",
link: [
bucket,
bucketNew,
database,
AUTH_API_URL,
STRIPE_WEBHOOK_SECRET,
+1 -5
View File
@@ -2,9 +2,5 @@ import { domain } from "./stage"
new sst.cloudflare.StaticSite("Desktop", {
domain: "desktop." + domain,
path: "packages/desktop",
build: {
command: "bun turbo build",
output: "./dist",
},
path: "packages/app/public",
})
+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-PyoVOza+3WnwZbtpPF6uSN1zkyLsSG2VsgBfIMvIFAs="
"nodeModules": "sha256-CDOAY2h2AAcSuVqV1uyxDmfzSa/vV8lnXOKDgAC4mgg="
}
+14 -3
View File
@@ -4,7 +4,7 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.4",
"packageManager": "bun@1.3.5",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"typecheck": "bun turbo typecheck",
@@ -21,6 +21,7 @@
],
"catalog": {
"@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/diffs": "1.0.0-beta.3",
"@pierre/diffs": "1.0.2",
"@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"ai": "5.0.97",
@@ -42,6 +44,7 @@
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"zod": "4.1.8",
"remeda": "2.26.0",
"shiki": "3.20.0",
"solid-list": "0.3.0",
"tailwindcss": "4.1.11",
"virtua": "0.42.3",
@@ -54,6 +57,7 @@
}
},
"devDependencies": {
"@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:",
"husky": "9.1.7",
"prettier": "3.6.2",
@@ -61,7 +65,15 @@
"turbo": "2.5.6"
},
"dependencies": {
"@ai-sdk/cerebras": "1.0.33",
"@ai-sdk/cohere": "2.0.21",
"@ai-sdk/deepinfra": "1.0.30",
"@ai-sdk/gateway": "2.0.23",
"@ai-sdk/groq": "2.0.33",
"@ai-sdk/perplexity": "2.0.22",
"@ai-sdk/togetherai": "1.0.30",
"@aws-sdk/client-s3": "3.933.0",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"typescript": "catalog:"
@@ -78,7 +90,6 @@
"trustedDependencies": [
"esbuild",
"protobufjs",
"sharp",
"tree-sitter",
"tree-sitter-bash",
"web-tree-sitter"
+1
View File
@@ -0,0 +1 @@
src/assets/theme.css
+34
View File
@@ -0,0 +1,34 @@
## Usage
Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`.
This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely be removed once you clone a template.
```bash
$ npm install # or pnpm install or yarn install
```
### Learn more on the [Solid Website](https://solidjs.com) and come chat with us on our [Discord](https://discord.com/invite/solidjs)
## Available Scripts
In the project directory, you can run:
### `npm run dev` or `npm start`
Runs the app in the development mode.<br>
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.<br>
### `npm run build`
Builds the app for production to the `dist` folder.<br>
It correctly bundles Solid in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.<br>
Your app is ready to be deployed!
## Deployment
You can deploy the `dist` folder to any static host provider (netlify, surge, now, etc.)
@@ -23,6 +23,6 @@
</script>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-screen"></div>
<script src="/src/index.tsx" type="module"></script>
<script src="/src/entry.tsx" type="module"></script>
</body>
</html>
+62
View File
@@ -0,0 +1,62 @@
{
"name": "@opencode-ai/app",
"version": "1.0.193",
"description": "",
"type": "module",
"exports": {
".": "./src/index.ts",
"./vite": "./vite.js"
},
"scripts": {
"typecheck": "tsgo -b",
"start": "vite",
"dev": "vite",
"build": "vite build",
"serve": "vite preview"
},
"license": "MIT",
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@tailwindcss/vite": "catalog:",
"@tsconfig/bun": "1.0.9",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vite-plugin-icons-spritesheet": "3.0.1",
"vite-plugin-solid": "catalog:"
},
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@shikijs/transformers": "3.9.2",
"@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": "catalog:",
"@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "0.3.0",
"luxon": "catalog:",
"marked": "16.2.0",
"marked-shiki": "1.2.1",
"remeda": "catalog:",
"shiki": "catalog:",
"solid-js": "catalog:",
"solid-list": "catalog:",
"tailwindcss": "catalog:",
"virtua": "catalog:",
"zod": "catalog:"
}
}
+92
View File
@@ -0,0 +1,92 @@
import "@/index.css"
import { ErrorBoundary, 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 { 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 { ErrorPage } from "./pages/error"
import { iife } from "@opencode-ai/util/iife"
declare global {
interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; port?: number }
}
}
const url = iife(() => {
const param = new URLSearchParams(document.location.search).get("url")
if (param) return param
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return "http://localhost:4096"
})
export function App() {
return (
<MetaProvider>
<Font />
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<DialogProvider>
<MarkedProvider>
<DiffComponentProvider component={Diff}>
<CodeComponentProvider component={Code}>
<GlobalSDKProvider url={url}>
<GlobalSyncProvider>
<LayoutProvider>
<NotificationProvider>
<Router
root={(props) => (
<CommandProvider>
<Layout>{props.children}</Layout>
</CommandProvider>
)}
>
<Route path="/" component={Home} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id || true} keyed>
<TerminalProvider>
<PromptProvider>
<Session />
</PromptProvider>
</TerminalProvider>
</Show>
)}
/>
</Route>
</Router>
</NotificationProvider>
</LayoutProvider>
</GlobalSyncProvider>
</GlobalSDKProvider>
</CodeComponentProvider>
</DiffComponentProvider>
</MarkedProvider>
</DialogProvider>
</ErrorBoundary>
</MetaProvider>
)
}
@@ -1,24 +1,24 @@
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import type { IconName } from "@opencode-ai/ui/icons/provider"
import { List, type ListRef } from "@opencode-ai/ui/list"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast"
import { iife } from "@opencode-ai/util/iife"
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { 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 { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { usePlatform } from "@/context/platform"
import { DialogSelectModel } from "./dialog-select-model"
import { DialogSelectProvider } from "./dialog-select-provider"
export function DialogConnectProvider(props: { provider: string }) {
const dialog = useDialog()
@@ -108,20 +108,18 @@ export function DialogConnectProvider(props: { provider: string }) {
async function complete() {
await globalSDK.client.global.dispose()
setTimeout(() => {
showToast({
variant: "success",
icon: "circle-check",
title: `${provider().name} connected`,
description: `${provider().name} models are now available to use.`,
})
dialog.replace(() => <DialogSelectModel provider={props.provider} />)
}, 1000)
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.replace(() => <DialogSelectProvider />)
dialog.show(() => <DialogSelectProvider />)
return
}
if (store.authorization) {
@@ -133,7 +131,7 @@ export function DialogConnectProvider(props: { provider: string }) {
setStore("methodIndex", undefined)
return
}
dialog.replace(() => <DialogSelectProvider />)
dialog.show(() => <DialogSelectProvider />)
}
return (
@@ -156,7 +154,9 @@ export function DialogConnectProvider(props: { provider: string }) {
<div class="text-14-regular text-text-base">Select login method for {provider().name}.</div>
<div class="">
<List
ref={(ref) => (listRef = ref)}
ref={(ref) => {
listRef = ref
}}
items={methods}
key={(m) => m?.label}
onSelect={async (method, index) => {
@@ -165,7 +165,7 @@ export function DialogConnectProvider(props: { provider: string }) {
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-4">
<div class="w-full flex items-center gap-x-2">
<div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
<div class="w-2.5 h-0.5 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
</div>
@@ -177,7 +177,7 @@ export function DialogConnectProvider(props: { provider: string }) {
</Match>
<Match when={store.state === "pending"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-4">
<div class="flex items-center gap-x-2">
<Spinner />
<span>Authorization in progress...</span>
</div>
@@ -185,7 +185,7 @@ export function DialogConnectProvider(props: { provider: string }) {
</Match>
<Match when={store.state === "error"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-4">
<div class="flex items-center gap-x-2">
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
<span>Authorization failed: {store.error}</span>
</div>
@@ -352,7 +352,7 @@ export function DialogConnectProvider(props: { provider: string }) {
})
if (result.error) {
// TODO: show error
dialog.clear()
dialog.close()
return
}
await complete()
@@ -1,16 +1,15 @@
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"
import type { Component } from "solid-js"
import { useLocal } from "@/context/local"
import { popularProviders } from "@/hooks/use-providers"
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}`}
@@ -27,16 +26,24 @@ export const DialogManageModels: Component = () => {
}}
onSelect={(x) => {
if (!x) return
const visible = local.model.visible({ modelID: x.id, providerID: x.provider.id })
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">
<div class="w-full flex items-center justify-between gap-x-3">
<span>{i.name}</span>
<div onClick={(e) => e.stopPropagation()}>
<Switch
checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })}
checked={
!!local.model.visible({
modelID: i.id,
providerID: i.provider.id,
})
}
onChange={(checked) => {
local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
}}
@@ -1,12 +1,12 @@
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 { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { List } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useParams } from "@solidjs/router"
import { createMemo } from "solid-js"
import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local"
export function DialogSelectFile() {
const layout = useLayout()
@@ -18,7 +18,6 @@ export function DialogSelectFile() {
return (
<Dialog title="Select file">
<List
class="px-2.5"
search={{ placeholder: "Search files", autofocus: true }}
emptyMessage="No files found"
items={local.file.searchFiles}
@@ -27,12 +26,12 @@ export function DialogSelectFile() {
if (path) {
tabs().open("file://" + path)
}
dialog.clear()
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">
<div class="flex items-center gap-x-3 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">
@@ -1,15 +1,15 @@
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 { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List, ListRef } from "@opencode-ai/ui/list"
import type { IconName } from "@opencode-ai/ui/icons/provider"
import { List, type 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 { Tag } from "@opencode-ai/ui/tag"
import { type Component, onCleanup, onMount, Show } from "solid-js"
import { useLocal } from "@/context/local"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { DialogConnectProvider } from "./dialog-connect-provider"
import { DialogSelectProvider } from "./dialog-select-provider"
export const DialogSelectModelUnpaid: Component = () => {
const local = useLocal()
@@ -42,7 +42,7 @@ export const DialogSelectModelUnpaid: Component = () => {
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true,
})
dialog.clear()
dialog.close()
}}
>
{(i) => (
@@ -64,7 +64,7 @@ export const DialogSelectModelUnpaid: Component = () => {
<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"
class="w-full px-0"
key={(x) => x?.id}
items={providers.popular}
activeIcon="plus-small"
@@ -75,21 +75,12 @@ export const DialogSelectModelUnpaid: Component = () => {
}}
onSelect={(x) => {
if (!x) return
dialog.replace(() => <DialogConnectProvider provider={x.id} />)
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",
}}
/>
<div class="w-full flex items-center gap-x-3">
<ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
<span>{i.name}</span>
<Show when={i.id === "opencode"}>
<Tag>Recommended</Tag>
@@ -105,7 +96,7 @@ export const DialogSelectModelUnpaid: Component = () => {
class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
icon="dot-grid"
onClick={() => {
dialog.replace(() => <DialogSelectProvider />)
dialog.show(() => <DialogSelectProvider />)
}}
>
View all providers
@@ -28,14 +28,13 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
class="h-7 -my-1 text-14-medium"
icon="plus-small"
tabIndex={-1}
onClick={() => dialog.replace(() => <DialogSelectProvider />)}
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}`}
@@ -57,11 +56,11 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true,
})
dialog.clear()
dialog.close()
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-2.5">
<div class="w-full flex items-center gap-x-3">
<span>{i.name}</span>
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
<Tag>Free</Tag>
@@ -75,7 +74,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
<Button
variant="ghost"
class="ml-3 mt-5 mb-6 text-text-base self-start"
onClick={() => dialog.replace(() => <DialogManageModels />)}
onClick={() => dialog.show(() => <DialogManageModels />)}
>
Manage models
</Button>
@@ -15,7 +15,6 @@ export const DialogSelectProvider: Component = () => {
return (
<Dialog title="Connect provider">
<List
class="px-2.5"
search={{ placeholder: "Search providers", autofocus: true }}
activeIcon="plus-small"
key={(x) => x?.id}
@@ -34,21 +33,12 @@ export const DialogSelectProvider: Component = () => {
}}
onSelect={(x) => {
if (!x) return
dialog.replace(() => <DialogConnectProvider provider={x.id} />)
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",
}}
/>
<div class="px-1.25 w-full flex items-center gap-x-3">
<ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
<span>{i.name}</span>
<Show when={i.id === "opencode"}>
<Tag>Recommended</Tag>
+209
View File
@@ -0,0 +1,209 @@
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 { useCommand } from "@/context/command"
import { getFilename } from "@opencode-ai/util/path"
import { A, useParams } from "@solidjs/router"
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
onMobileMenuToggle?: () => void
}) {
const globalSync = useGlobalSync()
const globalSDK = useGlobalSDK()
const layout = useLayout()
const params = useParams()
const command = useCommand()
return (
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
<button
type="button"
class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
onClick={props.onMobileMenuToggle}
>
<Icon name="menu" size="small" />
</button>
<A
href="/"
classList={{
"hidden xl:flex": true,
"w-12 shrink-0 px-4 py-3.5": true,
"items-center justify-start self-stretch": true,
"border-r border-border-weak-base": true,
}}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
data-tauri-drag-region
>
<Mark class="shrink-0" />
</A>
<div class="pl-4 px-6 flex items-center justify-between gap-4 w-full">
<Show when={layout.projects.list().length > 0 && params.dir}>
{(directory) => {
const currentDirectory = createMemo(() => base64Decode(directory()))
const store = createMemo(() => globalSync.child(currentDirectory())[0])
const sessions = createMemo(() => (store().session ?? []).filter((s) => !s.parentID))
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
const shareEnabled = createMemo(() => store().config.share !== "disabled")
return (
<>
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center gap-2 min-w-0">
<div class="hidden xl:flex items-center gap-2">
<Select
options={layout.projects.list().map((project) => project.worktree)}
current={currentDirectory()}
label={(x) => getFilename(x)}
onSelect={(x) => (x ? props.navigateToProject(x) : undefined)}
class="text-14-regular text-text-base"
variant="ghost"
>
{/* @ts-ignore */}
{(i) => (
<div class="flex items-center gap-2">
<Icon name="folder" size="small" />
<div class="text-text-strong">{getFilename(i)}</div>
</div>
)}
</Select>
<div class="text-text-weaker">/</div>
</div>
<Select
options={sessions()}
current={currentSession()}
placeholder="New session"
label={(x) => x.title}
value={(x) => x.id}
onSelect={props.navigateToSession}
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
variant="ghost"
/>
</div>
<Show when={currentSession()}>
<Tooltip
class="hidden xl:block"
value={
<div class="flex items-center gap-2">
<span>New session</span>
<span class="text-icon-base text-12-medium">{command.keybind("session.new")}</span>
</div>
}
>
<Button as={A} href={`/${params.dir}/session`} icon="plus-small">
New session
</Button>
</Tooltip>
</Show>
</div>
<div class="flex items-center gap-4">
<Tooltip
class="hidden md:block shrink-0"
value={
<div class="flex items-center gap-2">
<span>Toggle review</span>
<span class="text-icon-base text-12-medium">{command.keybind("review.toggle")}</span>
</div>
}
>
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={layout.review.opened() ? "layout-right-full" : "layout-right"}
class="group-hover/review-toggle:hidden"
/>
<Icon
size="small"
name="layout-right-partial"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
size="small"
name={layout.review.opened() ? "layout-right" : "layout-right-full"}
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</Tooltip>
<Tooltip
class="hidden md:block shrink-0"
value={
<div class="flex items-center gap-2">
<span>Toggle terminal</span>
<span class="text-icon-base text-12-medium">{command.keybind("terminal.toggle")}</span>
</div>
}
>
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
size="small"
name="layout-bottom-partial"
class="hidden group-hover/terminal-toggle:inline-block"
/>
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</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>
</header>
)
}
@@ -1,7 +1,6 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { makePersisted } from "@solid-primitives/storage"
import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local"
import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt"
@@ -20,7 +19,10 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectModel } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers"
import { useCommand, formatKeybind } from "@/context/command"
import { useCommand } from "@/context/command"
import { persisted } from "@/utils/persist"
import { Identifier } from "@/utils/id"
import { SessionContextUsage } from "@/components/session-context-usage"
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
@@ -99,6 +101,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
placeholder: number
dragging: boolean
imageAttachments: ImageAttachmentPart[]
mode: "normal" | "shell"
applyingHistory: boolean
userHasEdited: boolean
}>({
popover: null,
historyIndex: -1,
@@ -106,18 +111,27 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
dragging: false,
imageAttachments: [],
mode: "normal",
applyingHistory: false,
userHasEdited: false,
})
const MAX_HISTORY = 100
const [history, setHistory] = makePersisted(
const [history, setHistory] = persisted(
"prompt-history.v1",
createStore<{
entries: Prompt[]
}>({
entries: [],
}),
)
const [shellHistory, setShellHistory] = persisted(
"prompt-history-shell.v1",
createStore<{
entries: Prompt[]
}>({
entries: [],
}),
{
name: "prompt-history.v1",
},
)
const clonePromptParts = (prompt: Prompt): Prompt =>
@@ -135,10 +149,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
const length = position === "start" ? 0 : promptLength(p)
setStore("applyingHistory", true)
setStore("userHasEdited", false)
prompt.set(p, length)
requestAnimationFrame(() => {
editorRef.focus()
setCursorPosition(editorRef, length)
setStore("applyingHistory", false)
})
}
@@ -380,31 +397,47 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const parseFromDOM = (): Prompt => {
const newParts: Prompt = []
let position = 0
editorRef.childNodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE) {
if (node.textContent) {
const content = node.textContent
newParts.push({ type: "text", content, start: position, end: position + content.length })
position += content.length
}
} else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type) {
switch ((node as HTMLElement).dataset.type) {
case "file":
const content = node.textContent!
newParts.push({
type: "file",
path: (node as HTMLElement).dataset.path!,
content,
start: position,
end: position + content.length,
})
position += content.length
break
default:
break
}
}
const pushText = (content: string) => {
if (!content) return
newParts.push({ type: "text", content, start: position, end: position + content.length })
position += content.length
}
const rangeText = (range: Range) => {
const fragment = range.cloneContents()
const container = document.createElement("div")
container.append(fragment)
return container.innerText
}
const files = Array.from(editorRef.querySelectorAll<HTMLElement>("[data-type=file]"))
let last: HTMLElement | undefined
files.forEach((file) => {
const before = document.createRange()
before.selectNodeContents(editorRef)
if (last) before.setStartAfter(last)
before.setEndBefore(file)
pushText(rangeText(before))
const content = file.textContent ?? ""
newParts.push({
type: "file",
path: file.dataset.path!,
content,
start: position,
end: position + content.length,
})
position += content.length
last = file
})
const after = document.createRange()
after.selectNodeContents(editorRef)
if (last) after.setStartAfter(last)
pushText(rangeText(after))
if (newParts.length === 0) newParts.push(...DEFAULT_PROMPT)
return newParts
}
@@ -413,25 +446,51 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const rawParts = parseFromDOM()
const cursorPosition = getCursorPosition(editorRef)
const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
const trimmed = rawText.replace(/\u200B/g, "").trim()
const hasNonText = rawParts.some((part) => part.type !== "text")
const shouldReset = trimmed.length === 0 && !hasNonText
const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
const slashMatch = rawText.match(/^\/(\S*)$/)
if (shouldReset) {
setStore("popover", null)
setStore("userHasEdited", false)
if (store.historyIndex >= 0 && !store.applyingHistory) {
setStore("historyIndex", -1)
setStore("savedPrompt", null)
}
if (prompt.dirty()) {
prompt.set(DEFAULT_PROMPT, 0)
}
return
}
if (atMatch) {
onInput(atMatch[1])
setStore("popover", "file")
} else if (slashMatch) {
slashOnInput(slashMatch[1])
setStore("popover", "slash")
const shellMode = store.mode === "shell"
if (!shellMode) {
const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
const slashMatch = rawText.match(/^\/(\S*)$/)
if (atMatch) {
onInput(atMatch[1])
setStore("popover", "file")
} else if (slashMatch) {
slashOnInput(slashMatch[1])
setStore("popover", "slash")
} else {
setStore("popover", null)
}
} else {
setStore("popover", null)
}
if (store.historyIndex >= 0) {
if (store.historyIndex >= 0 && !store.applyingHistory) {
setStore("historyIndex", -1)
setStore("savedPrompt", null)
}
if (!store.applyingHistory) {
setStore("userHasEdited", true)
}
prompt.set(rawParts, cursorPosition)
}
@@ -505,7 +564,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
sessionID: params.id!,
})
const addToHistory = (prompt: Prompt) => {
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
const text = prompt
.map((p) => ("content" in p ? p.content : ""))
.join("")
@@ -513,17 +572,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!text) return
const entry = clonePromptParts(prompt)
const lastEntry = history.entries[0]
const currentHistory = mode === "shell" ? shellHistory : history
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
const lastEntry = currentHistory.entries[0]
if (lastEntry) {
const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("")
if (lastText === text) return
}
setHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY))
setCurrentHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY))
}
const navigateHistory = (direction: "up" | "down") => {
const entries = history.entries
if (store.userHasEdited) return false
const entries = store.mode === "shell" ? shellHistory.entries : history.entries
const current = store.historyIndex
if (direction === "up") {
@@ -565,6 +628,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "!" && store.mode === "normal") {
const cursorPosition = getCursorPosition(editorRef)
if (cursorPosition === 0) {
setStore("mode", "shell")
setStore("popover", null)
event.preventDefault()
return
}
}
if (store.mode === "shell") {
const { collapsed, cursorPosition, textLength } = getCaretState()
if (event.key === "Escape") {
setStore("mode", "normal")
event.preventDefault()
return
}
if (event.key === "Backspace" && collapsed && cursorPosition === 0 && textLength === 0) {
setStore("mode", "normal")
event.preventDefault()
return
}
}
if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
if (store.popover === "file") {
onKeyDown(event)
@@ -577,13 +663,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
if (event.altKey || event.ctrlKey || event.metaKey) return
const { collapsed, cursorPosition, textLength } = getCaretState()
const { collapsed } = getCaretState()
if (!collapsed) return
const cursorPosition = getCursorPosition(editorRef)
const textLength = promptLength(prompt.current())
const textContent = editorRef.textContent ?? ""
const isEmpty = textContent.trim() === "" || textLength <= 1
const hasNewlines = textContent.includes("\n")
const inHistory = store.historyIndex >= 0
const atAbsoluteStart = cursorPosition === 0
const atAbsoluteEnd = cursorPosition === textLength
const allowUp = (inHistory && atAbsoluteEnd) || atAbsoluteStart
const allowDown = (inHistory && atAbsoluteStart) || atAbsoluteEnd
const atStart = cursorPosition <= (isEmpty ? 1 : 0)
const atEnd = cursorPosition >= (isEmpty ? textLength - 1 : textLength)
const allowUp = isEmpty || atStart || (!hasNewlines && !inHistory) || (inHistory && atEnd)
const allowDown = isEmpty || atEnd || (!hasNewlines && !inHistory) || (inHistory && atStart)
if (event.key === "ArrowUp") {
if (!allowUp) return
@@ -622,9 +714,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}
addToHistory(currentPrompt)
addToHistory(currentPrompt, store.mode)
setStore("historyIndex", -1)
setStore("savedPrompt", null)
setStore("userHasEdited", false)
let existing = info()
if (!existing) {
@@ -645,6 +738,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
: ""
return {
id: Identifier.ascending("part"),
type: "file" as const,
mime: "text/plain",
url: `file://${absolute}${query}`,
@@ -662,16 +756,35 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
}))
const isShellMode = store.mode === "shell"
tabs().setActive(undefined)
editorRef.innerHTML = ""
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
setStore("imageAttachments", [])
setStore("mode", "normal")
const model = {
modelID: local.model.current()!.id,
providerID: local.model.current()!.provider.id,
}
const agent = local.agent.current()!.name
if (isShellMode) {
sdk.client.session.shell({
sessionID: existing.id,
agent,
model,
command: text,
})
return
}
if (text.startsWith("/")) {
const [cmdName, ...args] = text.split(" ")
@@ -682,28 +795,40 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
sessionID: existing.id,
command: commandName,
arguments: args.join(" "),
agent: local.agent.current()!.name,
model: `${local.model.current()!.provider.id}/${local.model.current()!.id}`,
agent,
model: `${model.providerID}/${model.modelID}`,
})
return
}
}
const messageID = Identifier.ascending("message")
const textPart = {
id: Identifier.ascending("part"),
type: "text" as const,
text,
}
const requestParts = [textPart, ...fileAttachmentParts, ...imageAttachmentParts]
const optimisticParts = requestParts.map((part) => ({
...part,
sessionID: existing.id,
messageID,
}))
sync.session.addOptimisticMessage({
sessionID: existing.id,
messageID,
parts: optimisticParts,
agent,
model,
})
sdk.client.session.prompt({
sessionID: existing.id,
agent: local.agent.current()!.name,
model: {
modelID: local.model.current()!.id,
providerID: local.model.current()!.provider.id,
},
parts: [
{
type: "text",
text,
},
...fileAttachmentParts,
...imageAttachmentParts,
],
agent,
model,
messageID,
parts: requestParts,
})
}
@@ -765,8 +890,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
custom
</span>
</Show>
<Show when={cmd.keybind}>
<span class="text-12-regular text-text-subtle">{formatKeybind(cmd.keybind!)}</span>
<Show when={command.keybind(cmd.id)}>
<span class="text-12-regular text-text-subtle">{command.keybind(cmd.id)}</span>
</Show>
</div>
</button>
@@ -833,6 +958,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Show>
<div class="relative max-h-[240px] overflow-y-auto">
<div
data-component="prompt-input"
ref={(el) => {
editorRef = el
props.ref?.(el)
@@ -843,34 +969,73 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
classList={{
"w-full px-5 py-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&>[data-type=file]]:text-icon-info-active": true,
"font-mono!": store.mode === "shell",
}}
/>
<Show when={!prompt.dirty() && store.imageAttachments.length === 0}>
<div class="absolute top-0 left-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none">
Ask anything... "{PLACEHOLDERS[store.placeholder]}"
<div class="absolute top-0 inset-x-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
{store.mode === "shell"
? "Enter shell command..."
: `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
</div>
</Show>
</div>
<div class="relative p-3 flex items-center justify-between">
<div class="flex items-center justify-start gap-1">
<Select
options={local.agent.list().map((agent) => agent.name)}
current={local.agent.current().name}
onSelect={local.agent.set}
class="capitalize"
variant="ghost"
/>
<Button
as="div"
variant="ghost"
onClick={() =>
dialog.push(() => (providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />))
}
>
{local.model.current()?.name ?? "Select model"}
<span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
<Icon name="chevron-down" size="small" />
</Button>
<Switch>
<Match when={store.mode === "shell"}>
<div class="flex items-center gap-2 px-2 h-6">
<Icon name="console" size="small" class="text-icon-primary" />
<span class="text-12-regular text-text-primary">Shell</span>
<span class="text-12-regular text-text-weak">esc to exit</span>
</div>
</Match>
<Match when={store.mode === "normal"}>
<Tooltip
placement="top"
value={
<div class="flex items-center gap-2">
<span>Cycle agent</span>
<span class="text-icon-base text-12-medium">{command.keybind("agent.cycle")}</span>
</div>
}
>
<Select
options={local.agent.list().map((agent) => agent.name)}
current={local.agent.current().name}
onSelect={local.agent.set}
class="capitalize"
variant="ghost"
/>
</Tooltip>
<Tooltip
placement="top"
value={
<div class="flex items-center gap-2">
<span>Choose model</span>
<span class="text-icon-base text-12-medium">{command.keybind("model.choose")}</span>
</div>
}
>
<Button
as="div"
variant="ghost"
onClick={() =>
dialog.show(() =>
providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />,
)
}
>
{local.model.current()?.name ?? "Select model"}
<span class="hidden md:block ml-0.5 text-text-weak text-12-regular">
{local.model.current()?.provider.name}
</span>
<Icon name="chevron-down" size="small" />
</Button>
</Tooltip>
</Match>
</Switch>
<SessionContextUsage />
</div>
<div class="flex items-center gap-1 absolute right-2 bottom-2">
<input
@@ -884,15 +1049,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
e.currentTarget.value = ""
}}
/>
<Tooltip placement="top" value="Attach image">
<IconButton
type="button"
icon="photo"
variant="ghost"
class="h-10 w-8"
onClick={() => fileInputRef.click()}
/>
</Tooltip>
<Show when={store.mode === "normal"}>
<Tooltip placement="top" value="Attach image">
<IconButton
type="button"
icon="photo"
variant="ghost"
class="h-10 w-8"
onClick={() => fileInputRef.click()}
/>
</Tooltip>
</Show>
<Tooltip
placement="top"
inactive={!prompt.dirty() && !working()}
@@ -0,0 +1,64 @@
import { createMemo, Show } from "solid-js"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { useSync } from "@/context/sync"
import { useParams } from "@solidjs/router"
import { AssistantMessage } from "@opencode-ai/sdk/v2"
export function SessionContextUsage() {
const sync = useSync()
const params = useParams()
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const cost = createMemo(() => {
const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)
})
const context = createMemo(() => {
const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
if (!last) return
const total =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
const model = sync.data.provider.all.find((x) => x.id === last.providerID)?.models[last.modelID]
return {
tokens: total.toLocaleString(),
percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
}
})
return (
<Show when={context?.()}>
{(ctx) => (
<Tooltip
openDelay={300}
value={
<div class="flex flex-col gap-1 p-2">
<div class="flex justify-between gap-4">
<span class="text-text-weaker">Tokens</span>
<span class="text-text-strong">{ctx().tokens}</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-text-weaker">Usage</span>
<span class="text-text-strong">{ctx().percentage ?? 0}%</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-text-weaker">Cost</span>
<span class="text-text-strong">{cost()}</span>
</div>
</div>
}
placement="top"
>
<div class="flex items-center gap-1">
<span class="text-12-medium text-text-weak">{`${ctx().percentage ?? 0}%`}</span>
<ProgressCircle size={16} strokeWidth={2} percentage={ctx().percentage ?? 0} />
</div>
</Tooltip>
)}
</Show>
)
}
@@ -1,5 +1,5 @@
import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
import { ComponentProps, createEffect, onCleanup, onMount, splitProps } from "solid-js"
import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js"
import { useSDK } from "@/context/sdk"
import { SerializeAddon } from "@/addons/serialize"
import { LocalPTY } from "@/context/terminal"
@@ -31,7 +31,7 @@ export const Terminal = (props: TerminalProps) => {
term = new Term({
cursorBlink: true,
fontSize: 14,
fontFamily: "TX-02, monospace",
fontFamily: "IBM Plex Mono, monospace",
allowTransparency: true,
theme: prefersDark()
? {
@@ -148,6 +148,7 @@ export const Terminal = (props: TerminalProps) => {
<div
ref={container}
data-component="terminal"
data-prevent-autofocus
classList={{
...(local.classList ?? {}),
"size-full px-6 py-3 font-mono": true,
@@ -119,7 +119,6 @@ function DialogCommand(props: { options: CommandOption[] }) {
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)}
@@ -128,7 +127,7 @@ function DialogCommand(props: { options: CommandOption[] }) {
groupBy={(x) => x.category ?? ""}
onSelect={(option) => {
if (option) {
dialog.clear()
dialog.close()
option.onSelect?.("palette")
}
}}
@@ -174,8 +173,8 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const suspended = () => suspendCount() > 0
const showPalette = () => {
if (dialog.stack.length === 0) {
dialog.replace(() => <DialogCommand options={options().filter((x) => !x.disabled)} />)
if (!dialog.active) {
dialog.show(() => <DialogCommand options={options().filter((x) => !x.disabled)} />)
}
}
@@ -226,6 +225,11 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}
}
},
keybind(id: string) {
const option = options().find((x) => x.id === id || x.id === "suggested." + id)
if (!option?.keybind) return ""
return formatKeybind(option.keybind)
},
show: showPalette,
keybinds(enabled: boolean) {
setSuspendCount((count) => count + (enabled ? -1 : 1))
@@ -1,30 +1,32 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
import { usePlatform } from "./platform"
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
name: "GlobalSDK",
init: (props: { url: string }) => {
const abort = new AbortController()
const sdk = createOpencodeClient({
const eventSdk = createOpencodeClient({
baseUrl: props.url,
signal: abort.signal,
// signal: AbortSignal.timeout(1000 * 60 * 10),
})
const emitter = createGlobalEmitter<{
[key: string]: Event
}>()
sdk.global.event().then(async (events) => {
eventSdk.global.event().then(async (events) => {
for await (const event of events.stream) {
// console.log("event", event)
emitter.emit(event.directory ?? "global", event.payload)
}
})
onCleanup(() => {
abort.abort()
const platform = usePlatform()
const sdk = createOpencodeClient({
baseUrl: props.url,
signal: AbortSignal.timeout(1000 * 60 * 10),
fetch: platform.fetch,
throwOnError: true,
})
return { url: props.url, client: sdk, event: emitter }
+376
View File
@@ -0,0 +1,376 @@
import {
type Message,
type Agent,
type Session,
type Part,
type Config,
type Path,
type File,
type FileNode,
type Project,
type FileDiff,
type Todo,
type SessionStatus,
type ProviderListResponse,
type ProviderAuthResponse,
type Command,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { useGlobalSDK } from "./global-sdk"
import { ErrorPage, type InitError } from "../pages/error"
import { createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
type State = {
ready: boolean
agent: Agent[]
command: Command[]
project: string
provider: ProviderListResponse
config: Config
path: Path
session: Session[]
session_status: {
[sessionID: string]: SessionStatus
}
session_diff: {
[sessionID: string]: FileDiff[]
}
todo: {
[sessionID: string]: Todo[]
}
limit: number
message: {
[sessionID: string]: Message[]
}
part: {
[messageID: string]: Part[]
}
node: FileNode[]
changes: File[]
}
function createGlobalSync() {
const globalSDK = useGlobalSDK()
const [globalStore, setGlobalStore] = createStore<{
ready: boolean
error?: InitError
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 (!directory) console.error("No directory provided")
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) {
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))
// Include up to the limit, plus any updated in the last 4 hours
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)
})
.catch((err) => {
console.error("Failed to load sessions", err)
const project = getFilename(directory)
showToast({ title: `Failed to load sessions for ${project}`, description: err.message })
})
}
async function bootstrapInstance(directory: string) {
if (!directory) return
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) => retry(p).catch((e) => setGlobalStore("error", e))))
.then(() => setStore("ready", true))
.catch((e) => setGlobalStore("error", e))
}
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.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() {
const health = await globalSDK.client.global.health().then((x) => x.data)
if (!health?.healthy) {
setGlobalStore(
"error",
new Error(`Could not connect to server. Is there a server running at \`${globalSDK.url}\`?`),
)
return
}
return Promise.all([
retry(() =>
globalSDK.client.path.get().then((x) => {
setGlobalStore("path", x.data!)
}),
),
retry(() =>
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)),
)
}),
),
retry(() =>
globalSDK.client.provider.list().then((x) => {
setGlobalStore("provider", x.data ?? {})
}),
),
retry(() =>
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
}
@@ -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]
@@ -27,12 +27,15 @@ type SessionTabs = {
all: string[]
}
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
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: {
@@ -44,13 +47,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
height: 280,
},
review: {
state: "pane" as "pane" | "tab",
opened: true,
},
session: {
width: 600,
},
sessionTabs: {} as Record<string, SessionTabs>,
}),
{
name: "layout.v3",
},
)
const usedColors = new Set<AvatarColorKey>()
@@ -63,21 +66,22 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
function enrich(project: { worktree: string; expanded: boolean }) {
const metadata = globalSync.data.project.find((x) => x.worktree === project.worktree)
if (!metadata) return []
return [
{
...project,
...metadata,
...(metadata ?? {}),
},
]
}
function colorize(project: Project & { expanded: boolean }) {
function colorize(project: LocalProject) {
if (project.icon?.color) return project
const color = pickAvailableColor()
usedColors.add(color)
project.icon = { ...project.icon, color }
globalSdk.client.project.update({ projectID: project.id, icon: { color } })
if (project.id) {
globalSdk.client.project.update({ projectID: project.id, icon: { color } })
}
return project
}
@@ -93,10 +97,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
})
return {
ready,
projects: {
list,
open(directory: string) {
if (store.projects.find((x) => x.worktree === directory)) return
if (store.projects.find((x) => x.worktree === directory)) {
return
}
globalSync.project.loadSessions(directory)
setStore("projects", (x) => [{ worktree: directory, expanded: true }, ...x])
},
@@ -104,10 +111,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("projects", (x) => x.filter((x) => x.worktree !== directory))
},
expand(directory: string) {
setStore("projects", (x) => x.map((x) => (x.worktree === directory ? { ...x, expanded: true } : x)))
const index = store.projects.findIndex((x) => x.worktree === directory)
if (index !== -1) setStore("projects", index, "expanded", true)
},
collapse(directory: string) {
setStore("projects", (x) => x.map((x) => (x.worktree === directory ? { ...x, expanded: false } : x)))
const index = store.projects.findIndex((x) => x.worktree === directory)
if (index !== -1) setStore("projects", index, "expanded", false)
},
move(directory: string, toIndex: number) {
setStore("projects", (projects) => {
@@ -153,12 +162,25 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
},
review: {
state: createMemo(() => store.review?.state ?? "closed"),
pane() {
setStore("review", "state", "pane")
opened: createMemo(() => store.review?.opened ?? true),
open() {
setStore("review", "opened", true)
},
tab() {
setStore("review", "state", "tab")
close() {
setStore("review", "opened", false)
},
toggle() {
setStore("review", "opened", (x) => !x)
},
},
session: {
width: createMemo(() => store.session?.width ?? 600),
resize(width: number) {
if (!store.session) {
setStore("session", { width })
} else {
setStore("session", "width", width)
}
},
},
tabs(sessionKey: string) {
@@ -182,14 +204,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
},
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)) {
@@ -7,8 +7,8 @@ import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { base64Encode } from "@opencode-ai/util/encode"
import { useProviders } from "@/hooks/use-providers"
import { makePersisted } from "@solid-primitives/storage"
import { DateTime } from "luxon"
import { persisted } from "@/utils/persist"
export type LocalFile = FileNode &
Partial<{
@@ -110,7 +110,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})()
const model = (() => {
const [store, setStore] = makePersisted(
const [store, setStore, _, modelReady] = persisted(
"model.v1",
createStore<{
user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
recent: ModelKey[]
@@ -118,7 +119,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
user: [],
recent: [],
}),
{ name: "model.v1" },
)
const [ephemeral, setEphemeral] = createStore<{
@@ -242,6 +242,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
return {
ready: modelReady,
current,
recent,
list,
@@ -336,6 +337,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const load = async (path: string) => {
const relativePath = relative(path)
await sdk.client.file.read({ path: relativePath }).then((x) => {
if (!store.node[relativePath]) return
setStore(
"node",
relativePath,
@@ -358,7 +360,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const init = async (path: string) => {
const relativePath = relative(path)
if (!store.node[relativePath]) await fetch(path)
if (store.node[relativePath].loaded) return
if (store.node[relativePath]?.loaded) return
return load(relativePath)
}
@@ -378,7 +380,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
context.addActive()
if (options?.pinned) setStore("node", path, "pinned", true)
if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
if (store.node[relativePath].loaded) return
if (store.node[relativePath]?.loaded) return
return load(relativePath)
}
@@ -424,7 +426,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
init,
expand(path: string) {
setStore("node", path, "expanded", true)
if (store.node[path].loaded) return
if (store.node[path]?.loaded) return
setStore("node", path, "loaded", true)
list(path)
},
@@ -1,6 +1,5 @@
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"
@@ -8,6 +7,7 @@ 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
@@ -31,25 +31,26 @@ export type Notification = TurnCompleteNotification | ErrorNotification
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification",
init: () => {
const idlePlayer = makeAudioPlayer(idleSound)
const errorPlayer = makeAudioPlayer(errorSound)
let idlePlayer: ReturnType<typeof makeAudioPlayer> | undefined
let errorPlayer: ReturnType<typeof makeAudioPlayer> | undefined
try {
idlePlayer = makeAudioPlayer(idleSound)
errorPlayer = makeAudioPlayer(errorSound)
} catch (err) {
console.log("Failed to load audio", err)
}
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const [store, setStore] = makePersisted(
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
@@ -65,7 +66,9 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
const isChild = match.found && syncStore.session[match.index].parentID
if (isChild) break
idlePlayer.play()
try {
idlePlayer?.play()
} catch {}
setStore("list", store.list.length, {
...base,
type: "turn-complete",
@@ -81,7 +84,9 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const isChild = match.found && syncStore.session[match.index].parentID
if (isChild) break
}
errorPlayer.play()
try {
errorPlayer?.play()
} catch {}
setStore("list", store.list.length, {
...base,
type: "error",
@@ -94,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,9 +1,16 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
export type Platform = {
/** Platform discriminator */
platform: "web" | "tauri"
/** Open a URL in the default browser */
openLink(url: string): void
/** Restart the app */
restart(): Promise<void>
/** Open native directory picker dialog (Tauri only) */
openDirectoryPickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null>
@@ -13,8 +20,17 @@ export type Platform = {
/** Save file picker dialog (Tauri only) */
saveFilePickerDialog?(opts?: { title?: string; defaultPath?: string }): Promise<string | null>
/** Open a URL in the default browser */
openLink(url: string): void
/** Storage mechanism, defaults to localStorage */
storage?: (name?: string) => SyncStorage | AsyncStorage
/** Check for updates (Tauri only) */
checkUpdate?(): Promise<{ updateAvailable: boolean; version?: string }>
/** Install updates (Tauri only) */
update?(): Promise<void>
/** Fetch override */
fetch?: typeof fetch
}
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
@@ -1,9 +1,9 @@
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo } from "solid-js"
import { makePersisted } from "@solid-primitives/storage"
import { useParams } from "@solidjs/router"
import { TextSelection } from "./local"
import { persisted } from "@/utils/persist"
interface PartBase {
content: string
@@ -77,7 +77,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const params = useParams()
const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`)
const [store, setStore] = makePersisted(
const [store, setStore, _, ready] = persisted(
name(),
createStore<{
prompt: Prompt
cursor?: number
@@ -85,12 +86,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
}),
{
name: name(),
},
)
return {
ready,
current: createMemo(() => store.prompt),
cursor: createMemo(() => store.cursor),
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
@@ -1,18 +1,20 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
import { useGlobalSDK } from "./global-sdk"
import { usePlatform } from "./platform"
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: { directory: string }) => {
const platform = usePlatform()
const globalSDK = useGlobalSDK()
const abort = new AbortController()
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
signal: abort.signal,
signal: AbortSignal.timeout(1000 * 60 * 10),
fetch: platform.fetch,
directory: props.directory,
throwOnError: true,
})
const emitter = createGlobalEmitter<{
@@ -23,10 +25,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
emitter.emit(event.type, event)
})
onCleanup(() => {
abort.abort()
})
return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url }
},
})
@@ -1,9 +1,11 @@
import { produce } from "solid-js/store"
import { createMemo } from "solid-js"
import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
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,12 +32,40 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
if (match.found) return store.session[match.index]
return undefined
},
addOptimisticMessage(input: {
sessionID: string
messageID: string
parts: Part[]
agent: string
model: { providerID: string; modelID: string }
}) {
const message: Message = {
id: input.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, input.messageID, (m) => m.id)
messages.splice(result.index, 0, message)
}
draft.part[input.messageID] = input.parts.slice()
}),
)
},
async sync(sessionID: string, _isRetry = false) {
const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100 }),
sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID }),
retry(() => sdk.client.session.get({ sessionID })),
retry(() => sdk.client.session.messages({ sessionID, limit: 100 })),
retry(() => sdk.client.session.todo({ sessionID })),
retry(() => sdk.client.session.diff({ sessionID })),
])
setStore(
produce((draft) => {
@@ -1,9 +1,9 @@
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo } from "solid-js"
import { makePersisted } from "@solid-primitives/storage"
import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk"
import { persisted } from "@/utils/persist"
export type LocalPTY = {
id: string
@@ -21,19 +21,18 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
const params = useParams()
const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
const [store, setStore] = makePersisted(
const [store, setStore, _, ready] = persisted(
name(),
createStore<{
active?: string
all: LocalPTY[]
}>({
all: [],
}),
{
name: name(),
},
)
return {
ready,
all: createMemo(() => Object.values(store.all)),
active: createMemo(() => store.active),
new() {
@@ -15,6 +15,9 @@ const platform: Platform = {
openLink(url: string) {
window.open(url, "_blank")
},
restart: async () => {
window.location.reload()
},
}
render(
@@ -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()) {
@@ -6,7 +6,6 @@ import { LocalProvider } from "@/context/local"
import { base64Decode } from "@opencode-ai/util/encode"
import { DataProvider } from "@opencode-ai/ui/context"
import { iife } from "@opencode-ai/util/iife"
import { DialogProvider, DialogRoot } from "@opencode-ai/ui/context/dialog"
export default function Layout(props: ParentProps) {
const params = useParams()
@@ -21,11 +20,7 @@ export default function Layout(props: ParentProps) {
const sync = useSync()
return (
<DataProvider data={sync.data} directory={directory()}>
<LocalProvider>
<DialogProvider>
<DialogRoot>{props.children}</DialogRoot>
</DialogProvider>
</LocalProvider>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
)
})}
+155
View File
@@ -0,0 +1,155 @@
import { TextField } from "@opencode-ai/ui/text-field"
import { Logo } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button"
import { Component } from "solid-js"
import { usePlatform } from "@/context/platform"
import { Icon } from "@opencode-ai/ui/icon"
export type InitError = {
name: string
data: Record<string, unknown>
}
function isInitError(error: unknown): error is InitError {
return (
typeof error === "object" &&
error !== null &&
"name" in error &&
"data" in error &&
typeof (error as InitError).data === "object"
)
}
function formatInitError(error: InitError): string {
const data = error.data
switch (error.name) {
case "MCPFailed":
return `MCP server "${data.name}" failed. Note, opencode does not support MCP authentication yet.`
case "ProviderModelNotFoundError": {
const { providerID, modelID, suggestions } = data as {
providerID: string
modelID: string
suggestions?: string[]
}
return [
`Model not found: ${providerID}/${modelID}`,
...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
`Check your config (opencode.json) provider/model names`,
].join("\n")
}
case "ProviderInitError":
return `Failed to initialize provider "${data.providerID}". Check credentials and configuration.`
case "ConfigJsonError":
return `Config file at ${data.path} is not valid JSON(C)` + (data.message ? `: ${data.message}` : "")
case "ConfigDirectoryTypoError":
return `Directory "${data.dir}" in ${data.path} is not valid. Rename the directory to "${data.suggestion}" or remove it. This is a common typo.`
case "ConfigFrontmatterError":
return `Failed to parse frontmatter in ${data.path}:\n${data.message}`
case "ConfigInvalidError": {
const issues = Array.isArray(data.issues)
? data.issues.map(
(issue: { message: string; path: string[] }) => "↳ " + issue.message + " " + issue.path.join("."),
)
: []
return [`Config file at ${data.path} is invalid` + (data.message ? `: ${data.message}` : ""), ...issues].join(
"\n",
)
}
case "UnknownError":
return String(data.message)
default:
return data.message ? String(data.message) : JSON.stringify(data, null, 2)
}
}
function formatErrorChain(error: unknown, depth = 0, parentMessage?: string): string {
if (!error) return "Unknown error"
if (isInitError(error)) {
const message = formatInitError(error)
if (depth > 0 && parentMessage === message) return ""
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
return indent + message
}
if (error instanceof Error) {
const isDuplicate = depth > 0 && parentMessage === error.message
const parts: string[] = []
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
if (!isDuplicate) {
// Stack already includes error name and message, so prefer it
parts.push(indent + (error.stack ?? `${error.name}: ${error.message}`))
} else if (error.stack) {
// Duplicate message - only show the stack trace lines (skip message)
const trace = error.stack.split("\n").slice(1).join("\n").trim()
if (trace) {
parts.push(trace)
}
}
if (error.cause) {
const causeResult = formatErrorChain(error.cause, depth + 1, error.message)
if (causeResult) {
parts.push(causeResult)
}
}
return parts.join("\n\n")
}
if (typeof error === "string") {
if (depth > 0 && parentMessage === error) return ""
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
return indent + error
}
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
return indent + JSON.stringify(error, null, 2)
}
function formatError(error: unknown): string {
return formatErrorChain(error, 0)
}
interface ErrorPageProps {
error: unknown
}
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 bg-background-base font-sans">
<div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
<Logo class="w-58.5 opacity-12 shrink-0" />
<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={formatError(props.error)}
readOnly
copyable
multiline
class="max-h-96 w-full font-mono text-xs no-scrollbar whitespace-pre"
label="Error Details"
hideLabel
/>
<Button size="large" onClick={platform.restart}>
Restart
</Button>
<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>
)
}
@@ -1,7 +1,19 @@
import { createEffect, createMemo, createSignal, For, Match, ParentProps, Show, Switch, type JSX } from "solid-js"
import {
createEffect,
createMemo,
createSignal,
For,
Match,
onCleanup,
onMount,
ParentProps,
Show,
Switch,
type JSX,
} from "solid-js"
import { DateTime } from "luxon"
import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout, getAvatarColors } from "@/context/layout"
import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
import { Avatar } from "@opencode-ai/ui/avatar"
@@ -15,7 +27,7 @@ import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Spinner } from "@opencode-ai/ui/spinner"
import { getFilename } from "@opencode-ai/util/path"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Session, Project } from "@opencode-ai/sdk/v2/client"
import { Session } from "@opencode-ai/sdk/v2/client"
import { usePlatform } from "@/context/platform"
import { createStore, produce } from "solid-js/store"
import {
@@ -25,11 +37,10 @@ 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 { useProviders } from "@/hooks/use-providers"
import { Toast } from "@opencode-ai/ui/toast"
import { showToast, Toast } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "@/context/global-sdk"
import { useNotification } from "@/context/notification"
import { Binary } from "@opencode-ai/util/binary"
@@ -37,23 +48,36 @@ import { Header } from "@/components/header"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectProvider } from "@/components/dialog-select-provider"
import { useCommand } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
export default function Layout(props: ParentProps) {
const [store, setStore] = createStore({
lastSession: {} as { [directory: string]: string },
activeDraggable: undefined as string | undefined,
mobileSidebarOpen: false,
mobileProjectsExpanded: {} as Record<string, boolean>,
})
let scrollContainerRef: HTMLDivElement | undefined
function scrollToSession(sessionId: string) {
if (!scrollContainerRef) return
const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
if (element) {
element.scrollIntoView({ block: "center", behavior: "smooth" })
}
const mobileSidebar = {
open: () => store.mobileSidebarOpen,
show: () => setStore("mobileSidebarOpen", true),
hide: () => setStore("mobileSidebarOpen", false),
toggle: () => setStore("mobileSidebarOpen", (x) => !x),
}
const mobileProjects = {
expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true,
expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true),
collapse: (directory: string) => setStore("mobileProjectsExpanded", directory, false),
}
let scrollContainerRef: HTMLDivElement | undefined
const xlQuery = window.matchMedia("(min-width: 1280px)")
const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches)
const handleViewportChange = (e: MediaQueryListEvent) => setIsLargeViewport(e.matches)
xlQuery.addEventListener("change", handleViewportChange)
onCleanup(() => xlQuery.removeEventListener("change", handleViewportChange))
const params = useParams()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
@@ -65,6 +89,33 @@ export default function Layout(props: ParentProps) {
const dialog = useDialog()
const command = useCommand()
onMount(async () => {
if (platform.checkUpdate && platform.update && platform.restart) {
const { updateAvailable, version } = await platform.checkUpdate()
if (updateAvailable) {
showToast({
persistent: true,
icon: "download",
title: "Update available",
description: `A new version of OpenCode (${version}) is now available to install.`,
actions: [
{
label: "Install and restart",
onClick: async () => {
await platform.update!()
await platform.restart!()
},
},
{
label: "Not yet",
onClick: "dismiss",
},
],
})
}
}
})
function flattenSessions(sessions: Session[]): Session[] {
const childrenMap = new Map<string, Session[]>()
for (const session of sessions) {
@@ -87,10 +138,26 @@ export default function Layout(props: ParentProps) {
return result
}
function scrollToSession(sessionId: string) {
if (!scrollContainerRef) return
const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
if (element) {
element.scrollIntoView({ block: "center", behavior: "smooth" })
}
}
function projectSessions(directory: string) {
if (!directory) return []
const sessions = globalSync
.child(directory)[0]
.session.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
return flattenSessions(sessions ?? [])
}
const currentSessions = createMemo(() => {
if (!params.dir) return []
const directory = base64Decode(params.dir)
return flattenSessions(globalSync.child(directory)[0].session ?? [])
return projectSessions(directory)
})
function navigateSessionByOffset(offset: number) {
@@ -127,7 +194,7 @@ export default function Layout(props: ParentProps) {
const nextProject = projects[nextProjectIndex]
if (!nextProject) return
const nextProjectSessions = flattenSessions(globalSync.child(nextProject.worktree)[0].session ?? [])
const nextProjectSessions = projectSessions(nextProject.worktree)
if (nextProjectSessions.length === 0) {
navigateToProject(nextProject.worktree)
return
@@ -194,7 +261,6 @@ export default function Layout(props: ParentProps) {
title: "Previous session",
category: "Session",
keybind: "alt+arrowup",
disabled: !params.dir,
onSelect: () => navigateSessionByOffset(-1),
},
{
@@ -202,7 +268,6 @@ export default function Layout(props: ParentProps) {
title: "Next session",
category: "Session",
keybind: "alt+arrowdown",
disabled: !params.dir,
onSelect: () => navigateSessionByOffset(1),
},
{
@@ -219,18 +284,20 @@ export default function Layout(props: ParentProps) {
])
function connectProvider() {
dialog.replace(() => <DialogSelectProvider />)
dialog.show(() => <DialogSelectProvider />)
}
function navigateToProject(directory: string | undefined) {
if (!directory) return
const lastSession = store.lastSession[directory]
navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
mobileSidebar.hide()
}
function navigateToSession(session: Session | undefined) {
if (!session) return
navigate(`/${params.dir}/session/${session?.id}`)
mobileSidebar.hide()
}
function openProject(directory: string, navigate = true) {
@@ -269,8 +336,12 @@ export default function Layout(props: ParentProps) {
})
createEffect(() => {
const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
if (isLargeViewport()) {
const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
} else {
document.documentElement.style.setProperty("--dialog-left-margin", "0px")
}
})
function getDraggableId(event: unknown): string | undefined {
@@ -303,30 +374,8 @@ export default function Layout(props: ParentProps) {
setStore("activeDraggable", undefined)
}
const ConstrainDragXAxis = (): JSX.Element => {
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 <></>
}
const ProjectAvatar = (props: {
project: Project
project: LocalProject
class?: string
expandable?: boolean
notify?: boolean
@@ -339,7 +388,7 @@ export default function Layout(props: ParentProps) {
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
return (
<div class="relative size-5 shrink-0 rounded-sm overflow-hidden">
<div class="relative size-5 shrink-0 rounded-sm">
<Avatar
fallback={name()}
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
@@ -369,7 +418,7 @@ export default function Layout(props: ParentProps) {
)
}
const ProjectVisual = (props: { project: Project & { expanded: boolean }; class?: string }): JSX.Element => {
const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => {
const name = createMemo(() => getFilename(props.project.worktree))
const current = createMemo(() => base64Decode(params.dir ?? ""))
return (
@@ -405,9 +454,10 @@ export default function Layout(props: ParentProps) {
const SessionItem = (props: {
session: Session
slug: string
project: Project
project: LocalProject
depth?: number
childrenMap: Map<string, Session[]>
mobile?: boolean
}): JSX.Element => {
const notification = useNotification()
const depth = props.depth ?? 0
@@ -415,11 +465,11 @@ export default function Layout(props: ParentProps) {
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
const notifications = createMemo(() => notification.session.unseen(props.session.id))
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
const isWorking = createMemo(
() =>
props.session.id !== params.id &&
globalSync.child(props.project.worktree)[0].session_status[props.session.id]?.type === "busy",
)
const isWorking = createMemo(() => {
if (props.session.id === params.id) return false
const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id]
return status?.type === "busy" || status?.type === "retry"
})
return (
<>
<div
@@ -428,7 +478,7 @@ export default function Layout(props: ParentProps) {
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
style={{ "padding-left": `${16 + depth * 12}px` }}
>
<Tooltip placement="right" value={props.session.title} gutter={10}>
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
<A
href={`${props.slug}/session/${props.session.id}`}
class="flex flex-col min-w-0 text-left w-full focus:outline-none"
@@ -475,7 +525,7 @@ export default function Layout(props: ParentProps) {
</A>
</Tooltip>
<div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
<Tooltip placement="right" value="Archive session">
<Tooltip placement={props.mobile ? "bottom" : "right"} value="Archive session">
<IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
</Tooltip>
</div>
@@ -488,6 +538,7 @@ export default function Layout(props: ParentProps) {
project={props.project}
depth={depth + 1}
childrenMap={props.childrenMap}
mobile={props.mobile}
/>
)}
</For>
@@ -495,12 +546,15 @@ export default function Layout(props: ParentProps) {
)
}
const SortableProject = (props: { project: Project & { expanded: boolean } }): JSX.Element => {
const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
const sortable = createSortable(props.project.worktree)
const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened())
const slug = createMemo(() => base64Encode(props.project.worktree))
const name = createMemo(() => getFilename(props.project.worktree))
const [store] = globalSync.child(props.project.worktree)
const sessions = createMemo(() => store.session ?? [])
const [store, setProjectStore] = globalSync.child(props.project.worktree)
const sessions = createMemo(() =>
store.session.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)),
)
const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
const childSessionsByParent = createMemo(() => {
const map = new Map<string, Session[]>()
@@ -513,13 +567,29 @@ export default function Layout(props: ParentProps) {
}
return map
})
const [expanded, setExpanded] = createSignal(true)
const hasMoreSessions = createMemo(() => store.session.length >= store.limit)
const loadMoreSessions = async () => {
setProjectStore("limit", (limit) => limit + 5)
await globalSync.project.loadSessions(props.project.worktree)
}
const isExpanded = createMemo(() =>
props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded,
)
const handleOpenChange = (open: boolean) => {
if (props.mobile) {
if (open) mobileProjects.expand(props.project.worktree)
else mobileProjects.collapse(props.project.worktree)
} else {
if (open) layout.projects.expand(props.project.worktree)
else layout.projects.collapse(props.project.worktree)
}
}
return (
// @ts-ignore
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Switch>
<Match when={layout.sidebar.opened()}>
<Collapsible variant="ghost" defaultOpen class="gap-2 shrink-0" onOpenChange={setExpanded}>
<Match when={showExpanded()}>
<Collapsible variant="ghost" open={isExpanded()} class="gap-2 shrink-0" onOpenChange={handleOpenChange}>
<Button
as={"div"}
variant="ghost"
@@ -530,7 +600,7 @@ export default function Layout(props: ParentProps) {
project={props.project}
class="group-hover/session:hidden"
expandable
notify={!expanded()}
notify={!isExpanded()}
/>
<span class="truncate text-14-medium text-text-strong">{name()}</span>
</Collapsible.Trigger>
@@ -559,6 +629,7 @@ export default function Layout(props: ParentProps) {
slug={slug()}
project={props.project}
childrenMap={childSessionsByParent()}
mobile={props.mobile}
/>
)}
</For>
@@ -569,7 +640,7 @@ export default function Layout(props: ParentProps) {
>
<div class="flex items-center self-stretch w-full">
<div class="flex-1 min-w-0">
<Tooltip placement="right" value="New session">
<Tooltip placement={props.mobile ? "bottom" : "right"} value="New session">
<A
href={`${slug()}/session`}
class="flex flex-col gap-1 min-w-0 text-left w-full focus:outline-none"
@@ -585,6 +656,18 @@ export default function Layout(props: ParentProps) {
</div>
</div>
</Show>
<Show when={hasMoreSessions()}>
<div class="relative w-full py-1">
<Button
variant="ghost"
class="flex w-full text-left justify-start text-12-medium opacity-50 px-3.5"
size="large"
onClick={loadMoreSessions}
>
Load more
</Button>
</div>
</Show>
</nav>
</Collapsible.Content>
</Collapsible>
@@ -612,31 +695,23 @@ export default function Layout(props: ParentProps) {
)
}
return (
<div class="relative flex-1 min-h-0 flex flex-col">
<Header navigateToProject={navigateToProject} navigateToSession={navigateToSession} />
<div class="flex-1 min-h-0 flex">
<div
classList={{
"relative @container w-12 pb-5 shrink-0 bg-background-base": true,
"flex flex-col gap-5.5 items-start self-stretch justify-between": true,
"border-r border-border-weak-base": true,
}}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
>
<Show when={layout.sidebar.opened()}>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={150}
max={window.innerWidth * 0.3}
collapseThreshold={80}
onResize={layout.sidebar.resize}
onCollapse={layout.sidebar.close}
/>
</Show>
<div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
<Tooltip class="shrink-0" placement="right" value="Toggle sidebar" inactive={layout.sidebar.opened()}>
const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
const expanded = () => sidebarProps.mobile || layout.sidebar.opened()
return (
<>
<div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
<Show when={!sidebarProps.mobile}>
<Tooltip
class="shrink-0"
placement="right"
value={
<div class="flex items-center gap-2">
<span>Toggle sidebar</span>
<span class="text-icon-base text-12-medium">{command.keybind("sidebar.toggle")}</span>
</div>
}
inactive={expanded()}
>
<Button
variant="ghost"
size="large"
@@ -667,102 +742,161 @@ export default function Layout(props: ParentProps) {
</Show>
</Button>
</Tooltip>
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
</Show>
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={sidebarProps.mobile ? undefined : scrollContainerRef}
class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={scrollContainerRef}
class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
>
<SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
<For each={layout.projects.list()}>{(project) => <SortableProject project={project} />}</For>
</SortableProvider>
</div>
<DragOverlay>
<ProjectDragOverlay />
</DragOverlay>
</DragDropProvider>
</div>
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
<Switch>
<Match when={!providers.paid().length && layout.sidebar.opened()}>
<div class="rounded-md bg-background-stronger shadow-xs-border-base">
<div class="p-3 flex flex-col gap-2">
<div class="text-12-medium text-text-strong">Getting started</div>
<div class="text-text-base">OpenCode includes free models so you can start immediately.</div>
<div class="text-text-base">Connect any provider to use models, inc. Claude, GPT, Gemini etc.</div>
</div>
<Tooltip placement="right" value="Connect provider" inactive={layout.sidebar.opened()}>
<Button
class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px"
size="large"
icon="plus"
onClick={connectProvider}
>
<Show when={layout.sidebar.opened()}>Connect provider</Show>
</Button>
</Tooltip>
<SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
<For each={layout.projects.list()}>
{(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />}
</For>
</SortableProvider>
</div>
<DragOverlay>
<ProjectDragOverlay />
</DragOverlay>
</DragDropProvider>
</div>
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
<Switch>
<Match when={!providers.paid().length && expanded()}>
<div class="rounded-md bg-background-stronger shadow-xs-border-base">
<div class="p-3 flex flex-col gap-2">
<div class="text-12-medium text-text-strong">Getting started</div>
<div class="text-text-base">OpenCode includes free models so you can start immediately.</div>
<div class="text-text-base">Connect any provider to use models, inc. Claude, GPT, Gemini etc.</div>
</div>
</Match>
<Match when={true}>
<Tooltip placement="right" value="Connect provider" inactive={layout.sidebar.opened()}>
<Tooltip placement="right" value="Connect provider" inactive={expanded()}>
<Button
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2"
variant="ghost"
class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px"
size="large"
icon="plus"
onClick={connectProvider}
>
<Show when={layout.sidebar.opened()}>Connect provider</Show>
Connect provider
</Button>
</Tooltip>
</Match>
</Switch>
<Show when={platform.openDirectoryPickerDialog}>
<Tooltip placement="right" value="Open project" inactive={layout.sidebar.opened()}>
</div>
</Match>
<Match when={true}>
<Tooltip placement="right" value="Connect provider" inactive={expanded()}>
<Button
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2"
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
variant="ghost"
size="large"
icon="folder-add-left"
onClick={chooseProject}
icon="plus"
onClick={connectProvider}
>
<Show when={layout.sidebar.opened()}>Open project</Show>
<Show when={expanded()}>Connect provider</Show>
</Button>
</Tooltip>
</Show>
{/* <Tooltip placement="right" value="Settings" inactive={layout.sidebar.opened()}> */}
{/* <Button */}
{/* disabled */}
{/* class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2" */}
{/* variant="ghost" */}
{/* size="large" */}
{/* icon="settings-gear" */}
{/* > */}
{/* <Show when={layout.sidebar.opened()}>Settings</Show> */}
{/* </Button> */}
{/* </Tooltip> */}
<Tooltip placement="right" value="Share feedback" inactive={layout.sidebar.opened()}>
</Match>
</Switch>
<Show when={platform.openDirectoryPickerDialog}>
<Tooltip
placement="right"
value={
<div class="flex items-center gap-2">
<span>Open project</span>
<Show when={!sidebarProps.mobile}>
<span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
</Show>
</div>
}
inactive={expanded()}
>
<Button
as={"a"}
href="https://opencode.ai/desktop-feedback"
target="_blank"
class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2"
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
variant="ghost"
size="large"
icon="bubble-5"
icon="folder-add-left"
onClick={chooseProject}
>
<Show when={layout.sidebar.opened()}>Share feedback</Show>
<Show when={expanded()}>Open project</Show>
</Button>
</Tooltip>
</Show>
<Tooltip placement="right" value="Share feedback" inactive={expanded()}>
<Button
as={"a"}
href="https://opencode.ai/desktop-feedback"
target="_blank"
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
variant="ghost"
size="large"
icon="bubble-5"
>
<Show when={expanded()}>Share feedback</Show>
</Button>
</Tooltip>
</div>
</>
)
}
return (
<div class="relative flex-1 min-h-0 flex flex-col">
<Header
navigateToProject={navigateToProject}
navigateToSession={navigateToSession}
onMobileMenuToggle={mobileSidebar.toggle}
/>
<div class="flex-1 min-h-0 flex">
<div
classList={{
"hidden xl:flex": true,
"relative @container w-12 pb-5 shrink-0 bg-background-base": true,
"flex-col gap-5.5 items-start self-stretch justify-between": true,
"border-r border-border-weak-base contain-strict": true,
}}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
>
<Show when={layout.sidebar.opened()}>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={150}
max={window.innerWidth * 0.3}
collapseThreshold={80}
onResize={layout.sidebar.resize}
onCollapse={layout.sidebar.close}
/>
</Show>
<SidebarContent />
</div>
<div class="xl:hidden">
<div
classList={{
"fixed inset-0 bg-black/50 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": mobileSidebar.open(),
"opacity-0 pointer-events-none": !mobileSidebar.open(),
}}
onClick={(e) => {
if (e.target === e.currentTarget) mobileSidebar.hide()
}}
/>
<div
classList={{
"@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between pt-12 pb-5 transition-transform duration-200 ease-out": true,
"translate-x-0": mobileSidebar.open(),
"-translate-x-full": !mobileSidebar.open(),
}}
onClick={(e) => e.stopPropagation()}
>
<SidebarContent mobile />
</div>
</div>
<main class="size-full overflow-x-hidden flex flex-col items-start">{props.children}</main>
<main class="size-full overflow-x-hidden flex flex-col items-start contain-strict">{props.children}</main>
</div>
<Toast.Region />
</div>
@@ -1,4 +1,19 @@
import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo, createEffect, on } from "solid-js"
import {
For,
onCleanup,
onMount,
Show,
Match,
Switch,
createResource,
createMemo,
createEffect,
on,
createRenderEffect,
batch,
} 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"
@@ -8,11 +23,11 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
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 { createAutoScroll } from "@opencode-ai/ui/hooks"
import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
import { SessionReview } from "@opencode-ai/ui/session-review"
import {
@@ -22,9 +37,8 @@ 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 { useTerminal, type LocalPTY } from "@/context/terminal"
@@ -37,10 +51,11 @@ 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 { 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()
@@ -48,6 +63,7 @@ export default function Page() {
const sync = useSync()
const terminal = useTerminal()
const dialog = useDialog()
const codeComponent = useCodeComponent()
const command = useCommand()
const params = useParams()
const navigate = useNavigate()
@@ -56,7 +72,6 @@ export default function Page() {
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] ?? []) : []))
@@ -65,7 +80,6 @@ export default function Page() {
.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()
@@ -73,15 +87,24 @@ export default function Page() {
})
const lastUserMessage = createMemo(() => visibleUserMessages()?.at(-1))
const [messageStore, setMessageStore] = createStore<{ messageId?: string }>({})
const [store, setStore] = createStore({
clickTimer: undefined as number | undefined,
activeDraggable: undefined as string | undefined,
activeTerminalDraggable: undefined as string | undefined,
userInteracted: false,
stepsExpanded: true,
mobileStepsExpanded: {} as Record<string, boolean>,
messageId: undefined as string | undefined,
})
const activeMessage = createMemo(() => {
if (!messageStore.messageId) return lastUserMessage()
if (!store.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)
const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
return found ?? lastUserMessage()
})
const setActiveMessage = (message: UserMessage | undefined) => {
setMessageStore("messageId", message?.id)
setStore("messageId", message?.id)
}
function navigateMessageByOffset(offset: number) {
@@ -103,33 +126,8 @@ export default function Page() {
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,
activeDraggable: undefined as string | undefined,
activeTerminalDraggable: undefined as string | undefined,
stepsExpanded: false,
})
let inputRef!: HTMLDivElement
createEffect(() => {
@@ -150,14 +148,35 @@ export default function Page() {
() => visibleUserMessages().at(-1)?.id,
(lastId, prevLastId) => {
if (lastId && prevLastId && lastId > prevLastId) {
setMessageStore("messageId", undefined)
setStore("messageId", undefined)
}
},
{ defer: true },
),
)
createEffect(() => {
params.id
const status = sync.data.session_status[params.id ?? ""] ?? { type: "idle" }
batch(() => {
setStore("userInteracted", false)
setStore("stepsExpanded", status.type !== "idle")
})
})
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? { type: "idle" })
const working = createMemo(() => status().type !== "idle" && activeMessage()?.id === lastUserMessage()?.id)
createRenderEffect((prev) => {
const isWorking = working()
if (!prev && isWorking) {
setStore("stepsExpanded", true)
}
if (prev && !isWorking && !store.userInteracted) {
setStore("stepsExpanded", false)
}
return isWorking
}, working())
command.register(() => [
{
@@ -176,7 +195,7 @@ export default function Page() {
category: "File",
keybind: "mod+p",
slash: "open",
onSelect: () => dialog.replace(() => <DialogSelectFile />),
onSelect: () => dialog.show(() => <DialogSelectFile />),
},
// {
// id: "theme.toggle",
@@ -202,6 +221,15 @@ export default function Page() {
slash: "terminal",
onSelect: () => layout.terminal.toggle(),
},
{
id: "review.toggle",
title: "Toggle review",
description: "Show or hide the review panel",
category: "View",
keybind: "mod+b",
slash: "review",
onSelect: () => layout.review.toggle(),
},
{
id: "terminal.new",
title: "New terminal",
@@ -243,23 +271,32 @@ export default function Page() {
title: "Choose model",
description: "Select a different model",
category: "Model",
keybind: "mod+'",
slash: "model",
onSelect: () => dialog.replace(() => <DialogSelectModel />),
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",
keybind: "mod+z",
slash: "undo",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => {
@@ -289,7 +326,6 @@ export default function Page() {
title: "Redo",
description: "Redo the last undone message",
category: "Session",
keybind: "mod+shift+z",
slash: "redo",
disabled: !params.id || !info()?.revert?.messageID,
onSelect: async () => {
@@ -317,24 +353,15 @@ export default function Page() {
])
const handleKeyDown = (event: KeyboardEvent) => {
if ((document.activeElement as HTMLElement)?.dataset?.component === "terminal") return
if (dialog.stack.length > 0) return
if (event.key === "PageUp" || event.key === "PageDown") {
const scrollContainer = document.querySelector('[data-slot="session-turn-content"]') as HTMLElement
if (scrollContainer) {
event.preventDefault()
const scrollAmount = scrollContainer.clientHeight * 0.8
scrollContainer.scrollBy({
top: event.key === "PageUp" ? -scrollAmount : scrollAmount,
behavior: "instant",
})
}
return
const activeElement = document.activeElement as HTMLElement | undefined
if (activeElement) {
const isProtected = activeElement.closest("[data-prevent-autofocus]")
const isInput = /^(INPUT|TEXTAREA|SELECT)$/.test(activeElement.tagName) || activeElement.isContentEditable
if (isProtected || isInput) return
}
if (dialog.active) return
const focused = document.activeElement === inputRef
if (focused) {
if (activeElement === inputRef) {
if (event.key === "Escape") inputRef?.blur()
return
}
@@ -515,304 +542,321 @@ 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 showTabs = createMemo(() => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0))
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 mobileWorking = createMemo(() => status().type !== "idle")
const mobileAutoScroll = createAutoScroll({
working: mobileWorking,
onUserInteracted: () => setStore("userInteracted", true),
})
const wide = createMemo(() => layout.review.state() === "tab" || !diffs().length)
const MobileTurns = () => (
<div
ref={mobileAutoScroll.scrollRef}
onScroll={mobileAutoScroll.handleScroll}
onClick={mobileAutoScroll.handleInteraction}
class="relative mt-2 min-w-0 w-full h-full overflow-y-auto no-scrollbar pb-12"
>
<div ref={mobileAutoScroll.contentRef} class="flex flex-col gap-45 items-start justify-start mt-4">
<For each={visibleUserMessages()}>
{(message) => (
<SessionTurn
sessionID={params.id!}
messageID={message.id}
stepsExpanded={store.mobileStepsExpanded[message.id] ?? false}
onStepsExpandedToggle={() => setStore("mobileStepsExpanded", message.id, (x) => !x)}
onUserInteracted={() => setStore("userInteracted", true)}
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>
)
const NewSessionView = () => (
<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" />
<div class="text-12-medium text-text-weak">
{getDirectory(sync.data.path.directory)}
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
</div>
</div>
<Show when={sync.project}>
{(project) => (
<div class="flex justify-center items-center gap-3">
<Icon name="pencil-line" size="small" />
<div class="text-12-medium text-text-weak">
Last modified&nbsp;
<span class="text-text-strong">
{DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
</span>
</div>
</div>
)}
</Show>
</div>
)
const DesktopSessionContent = () => (
<Switch>
<Match when={params.id}>
<div class="flex items-start justify-start h-full min-h-0">
<SessionMessageRail
messages={visibleUserMessages()}
current={activeMessage()}
onMessageSelect={setActiveMessage}
wide={!showTabs()}
/>
<Show when={activeMessage()}>
<SessionTurn
sessionID={params.id!}
messageID={activeMessage()!.id}
stepsExpanded={store.stepsExpanded}
onStepsExpandedToggle={() => setStore("stepsExpanded", (x) => !x)}
onUserInteracted={() => setStore("userInteracted", true)}
classes={{
root: "pb-20 flex-1 min-w-0",
content: "pb-20",
container:
"w-full " +
(!showTabs() ? "max-w-200 mx-auto px-6" : visibleUserMessages().length > 1 ? "pr-6 pl-18" : "px-6"),
}}
/>
</Show>
</div>
</Match>
<Match when={true}>
<NewSessionView />
</Match>
</Switch>
)
return (
<div class="relative bg-background-base size-full overflow-x-hidden flex flex-col">
<div class="min-h-0 grow w-full">
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={tabs().active() ?? "chat"} onChange={tabs().open}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List>
<Tabs.Trigger value="chat">
<div class="flex gap-x-[17px] items-center">
<div>Session</div>
<Tooltip
value={`${new Intl.NumberFormat("en-US", {
notation: "compact",
compactDisplay: "short",
}).format(tokens() ?? 0)} Tokens`}
class="flex items-center gap-1.5"
>
<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" && diffs().length}>
<Tabs.Trigger
value="review"
closeButton={
<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={diffs()}>
<DiffChanges changes={diffs()} variant="bars" />
</Show>
<div class="flex items-center gap-1.5">
<div>Review</div>
<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">
{info()?.summary?.files ?? 0}
</div>
</Show>
</div>
</div>
</Tabs.Trigger>
</Show>
<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">
<Tooltip value="Open file" class="flex items-center">
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={() => dialog.replace(() => <DialogSelectFile />)}
/>
</Tooltip>
</div>
</Tabs.List>
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
<div class="md:hidden flex-1 min-h-0 flex flex-col bg-background-stronger">
<Switch>
<Match when={!params.id}>
<div class="flex-1 min-h-0 overflow-hidden">
<NewSessionView />
</div>
<Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden">
<div
classList={{
"w-full flex-1 min-h-0": true,
grid: layout.review.state() === "tab",
flex: layout.review.state() === "pane",
}}
>
<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(),
}}
>
<Switch>
<Match when={params.id}>
<div class="flex items-start justify-start h-full min-h-0">
<SessionMessageRail
messages={visibleUserMessages()}
current={activeMessage()}
onMessageSelect={setActiveMessage}
wide={wide()}
/>
<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-146 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="text-20-medium text-text-weaker">New session</div>
<div class="flex justify-center items-center gap-3">
<Icon name="folder" size="small" />
<div class="text-12-medium text-text-weak">
{getDirectory(sync.data.path.directory)}
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
</div>
</div>
<Show when={sync.project}>
{(project) => (
<div class="flex justify-center items-center gap-3">
<Icon name="pencil-line" size="small" />
<div class="text-12-medium text-text-weak">
Last modified&nbsp;
<span class="text-text-strong">
{DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
</span>
</div>
</div>
)}
</Show>
</div>
</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">
<PromptInput
ref={(el) => {
inputRef = el
}}
/>
</div>
</div>
</div>
<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,
}}
>
<SessionReview
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
diffs={diffs()}
actions={
<Tooltip value="Open in tab">
<IconButton
icon="expand"
variant="ghost"
onClick={() => {
layout.review.tab()
tabs().setActive("review")
}}
/>
</Tooltip>
}
/>
</div>
</Show>
</div>
</Tabs.Content>
<Show when={layout.review.state() === "tab" && diffs().length}>
<Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden">
<div
classList={{
"relative pt-3 flex-1 min-h-0 overflow-hidden": true,
}}
>
</Match>
<Match when={diffs().length > 0}>
<Tabs class="flex-1 min-h-0 flex flex-col pb-28">
<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="flex-1 !overflow-hidden">
<MobileTurns />
</Tabs.Content>
<Tabs.Content forceMount value="review" class="flex-1 !overflow-hidden hidden data-[selected]:block">
<div class="relative h-full mt-6 overflow-y-auto no-scrollbar">
<SessionReview
classes={{
root: "pb-40",
header: "px-6",
container: "px-6",
}}
diffs={diffs()}
split
classes={{
root: "pb-32",
header: "px-4",
container: "px-4",
}}
/>
</div>
</Tabs.Content>
</Show>
<For each={tabs().all()}>
{(tab) => {
const [file] = createResource(
() => tab,
async (tab) => {
if (tab.startsWith("file://")) {
return local.file.node(tab.replace("file://", ""))
}
return undefined
},
)
return (
<Tabs.Content value={tab} class="select-text mt-3">
<Switch>
<Match when={file()}>
{(f) => (
<Code
file={{
name: f().path,
contents: f().content?.content ?? "",
cacheKey: checksum(f().content?.content ?? ""),
}}
overflow="scroll"
class="pb-40"
/>
)}
</Match>
</Switch>
</Tabs.Content>
)
}}
</For>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable}>
{(draggedFile) => {
const [file] = createResource(
() => draggedFile(),
async (tab) => {
if (tab.startsWith("file://")) {
return local.file.node(tab.replace("file://", ""))
}
return undefined
},
)
return (
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
<Show when={file()}>{(f) => <FileVisual active file={f()} />}</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
<Show when={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">
</Tabs>
</Match>
<Match when={true}>
<div class="flex-1 min-h-0 overflow-hidden">
<MobileTurns />
</div>
</Match>
</Switch>
<div class="absolute inset-x-0 bottom-4 flex flex-col justify-center items-center z-50 px-4">
<div class="w-full">
<PromptInput
ref={(el) => {
inputRef = el
}}
/>
</div>
</div>
</div>
<div class="hidden md:flex min-h-0 grow w-full">
<div
class="@container relative shrink-0 py-3 flex flex-col gap-6 min-h-0 h-full bg-background-stronger"
style={{ width: showTabs() ? `${layout.session.width()}px` : "100%" }}
>
<div class="flex-1 min-h-0 overflow-hidden">
<DesktopSessionContent />
</div>
<div class="absolute inset-x-0 bottom-8 flex flex-col justify-center items-center z-50">
<div
classList={{
"w-full px-6": true,
"max-w-200": !showTabs(),
}}
>
<PromptInput
ref={(el) => {
inputRef = el
}}
/>
</div>
</div>
<Show when={showTabs()}>
<ResizeHandle
direction="horizontal"
size={layout.session.width()}
min={450}
max={window.innerWidth * 0.45}
onResize={layout.session.resize}
/>
</Show>
</div>
<Show when={showTabs()}>
<div class="relative flex-1 min-w-0 h-full border-l border-border-weak-base">
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={tabs().active() ?? "review"} onChange={tabs().open}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List>
<Show when={diffs().length}>
<Tabs.Trigger value="review">
<div class="flex items-center gap-3">
<Show when={diffs()}>
<DiffChanges changes={diffs()} variant="bars" />
</Show>
<div class="flex items-center gap-1.5">
<div>Review</div>
<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">
{info()?.summary?.files ?? 0}
</div>
</Show>
</div>
</div>
</Tabs.Trigger>
</Show>
<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">
<Tooltip
value={
<div class="flex items-center gap-2">
<span>Open file</span>
<span class="text-icon-base text-12-medium">{command.keybind("file.open")}</span>
</div>
}
class="flex items-center"
>
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={() => dialog.show(() => <DialogSelectFile />)}
/>
</Tooltip>
</div>
</Tabs.List>
</div>
<Show when={diffs().length}>
<Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionReview
classes={{
root: "pb-40",
header: "px-6",
container: "px-6",
}}
diffs={diffs()}
split
/>
</div>
</Tabs.Content>
</Show>
<For each={tabs().all()}>
{(tab) => {
const [file] = createResource(
() => tab,
async (tab) => {
if (tab.startsWith("file://")) {
return local.file.node(tab.replace("file://", ""))
}
return undefined
},
)
return (
<Tabs.Content value={tab} class="select-text mt-3">
<Switch>
<Match when={file()}>
{(f) => (
<Dynamic
component={codeComponent}
file={{
name: f().path,
contents: f().content?.content ?? "",
cacheKey: checksum(f().content?.content ?? ""),
}}
overflow="scroll"
class="pb-40"
/>
)}
</Match>
</Switch>
</Tabs.Content>
)
}}
</For>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable}>
{(draggedFile) => {
const [file] = createResource(
() => draggedFile(),
async (tab) => {
if (tab.startsWith("file://")) {
return local.file.node(tab.replace("file://", ""))
}
return undefined
},
)
return (
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
<Show when={file()}>{(f) => <FileVisual active file={f()} />}</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
</div>
</Show>
</div>
<Show when={layout.terminal.opened()}>
<div
class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
class="hidden md:flex relative w-full flex-col shrink-0 border-t border-border-weak-base"
style={{ height: `${layout.terminal.height()}px` }}
>
<ResizeHandle
@@ -838,7 +882,15 @@ export default function Page() {
<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">
<Tooltip
value={
<div class="flex items-center gap-2">
<span>New terminal</span>
<span class="text-icon-base text-12-medium">{command.keybind("terminal.new")}</span>
</div>
}
class="flex items-center"
>
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
</Tooltip>
</div>
+99
View File
@@ -0,0 +1,99 @@
import z from "zod"
const prefixes = {
session: "ses",
message: "msg",
permission: "per",
user: "usr",
part: "prt",
pty: "pty",
} as const
const LENGTH = 26
let lastTimestamp = 0
let counter = 0
type Prefix = keyof typeof prefixes
export namespace Identifier {
export function schema(prefix: Prefix) {
return z.string().startsWith(prefixes[prefix])
}
export function ascending(prefix: Prefix, given?: string) {
return generateID(prefix, false, given)
}
export function descending(prefix: Prefix, given?: string) {
return generateID(prefix, true, given)
}
}
function generateID(prefix: Prefix, descending: boolean, given?: string): string {
if (!given) {
return create(prefix, descending)
}
if (!given.startsWith(prefixes[prefix])) {
throw new Error(`ID ${given} does not start with ${prefixes[prefix]}`)
}
return given
}
function create(prefix: Prefix, descending: boolean, timestamp?: number): string {
const currentTimestamp = timestamp ?? Date.now()
if (currentTimestamp !== lastTimestamp) {
lastTimestamp = currentTimestamp
counter = 0
}
counter += 1
let now = BigInt(currentTimestamp) * BigInt(0x1000) + BigInt(counter)
if (descending) {
now = ~now
}
const timeBytes = new Uint8Array(6)
for (let i = 0; i < 6; i += 1) {
timeBytes[i] = Number((now >> BigInt(40 - 8 * i)) & BigInt(0xff))
}
return prefixes[prefix] + "_" + bytesToHex(timeBytes) + randomBase62(LENGTH - 12)
}
function bytesToHex(bytes: Uint8Array): string {
let hex = ""
for (let i = 0; i < bytes.length; i += 1) {
hex += bytes[i].toString(16).padStart(2, "0")
}
return hex
}
function randomBase62(length: number): string {
const chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
const bytes = getRandomBytes(length)
let result = ""
for (let i = 0; i < length; i += 1) {
result += chars[bytes[i] % 62]
}
return result
}
function getRandomBytes(length: number): Uint8Array {
const bytes = new Uint8Array(length)
const cryptoObj = typeof globalThis !== "undefined" ? globalThis.crypto : undefined
if (cryptoObj && typeof cryptoObj.getRandomValues === "function") {
cryptoObj.getRandomValues(bytes)
return bytes
}
for (let i = 0; i < length; i += 1) {
bytes[i] = Math.floor(Math.random() * 256)
}
return bytes
}
+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]
}
+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 <></>
}
@@ -1,5 +1,7 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"composite": true,
"target": "ESNext",
"module": "ESNext",
"skipLibCheck": true,
@@ -10,11 +12,13 @@
"jsxImportSource": "solid-js",
"allowJs": true,
"strict": true,
"noEmit": false,
"emitDeclarationOnly": true,
"outDir": "node_modules/.ts-dist",
"isolatedModules": true,
"noEmit": true,
"emitDeclarationOnly": false,
"outDir": "node_modules/.ts-dist"
"paths": {
"@/*": ["./src/*"]
}
},
"references": [{ "path": "../desktop" }],
"include": ["src"]
"exclude": ["dist", "ts-dist"]
}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vite"
import desktopPlugin from "./vite"
export default defineConfig({
plugins: [desktopPlugin] as any,
server: {
host: "0.0.0.0",
allowedHosts: true,
port: 3000,
},
build: {
target: "esnext",
sourcemap: true,
},
})

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