Compare commits

..
227 Commits
Author SHA1 Message Date
opencode 585378cba0 release: v1.0.217
Update Nix Hashes / update (push) Has been cancelled
2025-12-30 19:01:36 +00:00
Dax Raad 8cd8393339 core: allow CORS requests from tauri://localhost 2025-12-30 13:58:41 -05:00
GitHub Action b184b2fb73 chore: generate 2025-12-30 18:34:33 +00:00
Aiden Cline c88c2da9be fix: move variant toggle to command bar 2025-12-30 12:33:50 -06:00
opencode 9b04081ae0 release: v1.0.216
Update Nix Hashes / update (push) Has been cancelled
2025-12-30 18:07:37 +00:00
Dax Raad 7d2d87fa2c core: allow CORS requests from *.opencode.ai subdomains 2025-12-30 13:04:18 -05:00
GitHub Action 787f37b382 chore: generate 2025-12-30 17:59:01 +00:00
ja 8fa1af851c style(nix): use idiomatic inherit syntax (#6457) 2025-12-30 11:58:28 -06:00
opencode 73bc3e704e release: v1.0.215
Update Nix Hashes / update (push) Has been cancelled
2025-12-30 17:09:08 +00:00
Adam 8d2feed30e fix(desktop): more defensive agent access 2025-12-30 11:03:34 -06:00
GitHub Action 2d8d4e5dee chore: generate 2025-12-30 16:52:42 +00:00
Fayçal Mitidji b3784588ae Fix: High CPU / memory leak when filtering model list window to empty results (#6435) 2025-12-30 10:52:06 -06:00
opencode 104d52bc38 release: v1.0.214
Update Nix Hashes / update (push) Has been cancelled
2025-12-30 16:37:22 +00:00
Adam dff1fe2d28 fix(desktop): sort servers by health 2025-12-30 10:34:55 -06:00
Adam 72ab4260ee fix(desktop): don't persist fallback server urls 2025-12-30 10:31:48 -06:00
Adam 9e9b4a0555 fix(share): broken share pages 2025-12-30 10:27:06 -06:00
Adam e53192889c fix(app): better text selection 2025-12-30 10:21:37 -06:00
Rohan Mukherjee 23bbfb3d15 fix: cloudflare provider information (#6426) 2025-12-30 09:45:09 -06:00
opencode-agent[bot]andopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> 37da005a01 docs: projects, find.files, notifications (#6438)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2025-12-30 09:44:09 -06:00
Didier Durand 8b708242f1 chore: fix various typos (#6429) 2025-12-30 09:42:04 -06:00
GitHub Action 339d2dcb98 chore: generate 2025-12-30 15:28:59 +00:00
Adam bbc8678164 fix(desktop): share projects across all local servers 2025-12-30 09:28:19 -06:00
opencode a1d54475fe release: v1.0.213
Update Nix Hashes / update (push) Has been cancelled
2025-12-30 15:26:35 +00:00
Adam 55c601d13a fix(desktop): don't hang on to dead server 2025-12-30 09:24:03 -06:00
Github Action 9115fac4c4 Update Nix flake.lock and hashes 2025-12-30 14:32:38 +00:00
Sebastian Herrlinger cfcb2c1fd8 upgrade opentui to v0.1.67, fixing split diff alignment and markdown jitter 2025-12-30 15:31:02 +01:00
GitHub Action 221fc62135 chore: generate 2025-12-30 13:37:48 +00:00
opencode faaef45384 release: v1.0.212
Update Nix Hashes / update (push) Has been cancelled
2025-12-30 13:37:47 +00:00
Adam 2d18d80ac3 chore: cleanup 2025-12-30 07:35:20 -06:00
Adam e0e07c5d48 feat(app): change server 2025-12-30 07:24:40 -06:00
opencode 281f9e6236 release: v1.0.211
Update Nix Hashes / update (push) Has been cancelled
2025-12-30 13:04:07 +00:00
Github Action f88903a901 Update Nix flake.lock and hashes 2025-12-30 12:58:37 +00:00
ryanwyler ad425a6a6a fix: revert opentui to 0.1.63 to fix streaming jitter regression (#6439) 2025-12-30 13:57:22 +01:00
GitHub Action e635d37027 chore: generate 2025-12-30 12:40:52 +00:00
Connor Adams 97081484d5 docs: global claude skills (#6436) 2025-12-30 06:40:19 -06:00
GitHub Action e451504496 ignore: update download stats 2025-12-30 2025-12-30 12:04:47 +00:00
Github Action 53211c5d37 Update Nix flake.lock and hashes 2025-12-30 11:00:29 +00:00
GitHub Action 98b6817e20 chore: generate 2025-12-30 11:00:29 +00:00
opencode f54d5377a4 release: v1.0.210
Update Nix Hashes / update (push) Has been cancelled
2025-12-30 11:00:28 +00:00
Adam a576fdb5e4 feat(web): open projects 2025-12-30 04:57:37 -06:00
Adam ae53f876f1 feat(desktop): readline shortcuts 2025-12-30 04:57:36 -06:00
Adam a7beba5aa9 chore(desktop): disable sourcemap 2025-12-30 04:57:36 -06:00
Adam e9ef72c20f feat(desktop): more mono (nerd) fonts 2025-12-30 04:57:35 -06:00
Adam fa1ac7bc95 feat(desktop): system notifications 2025-12-30 04:57:35 -06:00
Aiden Cline c82ab649e2 ignore: fix bug from variants pr, prevent createEffect issue 2025-12-30 00:14:10 -06:00
Aiden Cline abc7eed92b tweak: read global claude skills too (#6420) 2025-12-29 23:48:58 -06:00
Joachim IsakssonandJoachim Isaksson 1670d220da fix: prevent model list corruption from SolidJS reactivity (#6359)
Co-authored-by: Joachim Isaksson <joachim.isaksson@centiro.com>
2025-12-29 23:45:15 -06:00
Jkker ddc4e34731 fix(mdns): use named import for bonjour-service (resolves #6422) (#6423) 2025-12-29 23:29:34 -06:00
GitHub Action af99d83709 chore: generate 2025-12-30 03:44:29 +00:00
Aiden ClineandGithub Action ed0c0d90be feat: add variants toggle (#6325)
Co-authored-by: Github Action <action@github.com>
2025-12-29 21:43:50 -06:00
Aiden Cline e1dd9c4ccb ci: improve changelog script 2025-12-29 21:15:36 -06:00
Eduardo Santos de Brito 4657fa823f feat(plugin): expose server URL to plugins (#6373) 2025-12-29 21:05:08 -06:00
opencode-agent[bot]andopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> 1d589c7ac7 docs: nix formatter (#6414)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2025-12-29 20:54:25 -06:00
Aiden Cline 6b5a0fb261 ci: update token var 2025-12-29 18:16:17 -06:00
Github Action 6d93a7bf55 Update Nix flake.lock and hashes 2025-12-30 00:15:46 +00:00
Sebastian Herrlinger 4ca7ab6be8 upgrade opentui to v0.1.66, fixing split diff alignment 2025-12-30 01:14:18 +01:00
Silvio Ney 713d996b9f fix: Correct theme command in tui.mdx (#6410) 2025-12-29 18:11:07 -06:00
Aiden Cline aa16610021 ci: fix env 2025-12-29 17:52:36 -06:00
GitHub Action d98568fe7e chore: generate 2025-12-29 23:47:03 +00:00
Aiden Cline 1da3550c4d ci: improve err msg 2025-12-29 17:46:29 -06:00
opencode 0c48e6a116 release: v1.0.209
Update Nix Hashes / update (push) Has been cancelled
2025-12-29 23:37:04 +00:00
Adam ef266b2c74 fix(desktop): error page formatting 2025-12-29 17:33:59 -06:00
Aiden Cline 0a1cdc7a58 ci: use .env 2025-12-29 17:31:41 -06:00
Adam 2dec956a17 fix(desktop): better error messages 2025-12-29 17:29:56 -06:00
Aiden Cline ef8388f0ee Revert "feat: read global ~/.claude/skills"
This reverts commit a1c9a1b8c5.
2025-12-29 17:20:04 -06:00
opencode-agent[bot]opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-nodeAiden Cline
e5c5b5e872 docs: add run from source guide (#6405)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-12-29 17:12:42 -06:00
Aiden Cline a1c9a1b8c5 feat: read global ~/.claude/skills 2025-12-29 17:11:28 -06:00
Aiden Cline 76b012139a fix: add timeout to filewatcher subscriptions 2025-12-29 16:16:38 -06:00
Aiden Cline 02e5a19242 tweak: adjust git watcher to ignore files other than HEAD 2025-12-29 16:16:38 -06:00
af967648cb docs: opencode notificator plugin (fixed link) (#6341)
Co-authored-by: Ivan Pantic <panta@talentkit.io>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-29 16:03:35 -06:00
Dominik Engelhardt 504a668a26 Set smallOptions for google models on openrouter (#6362) 2025-12-29 16:01:31 -06:00
ja 5efb1c7b2d feat(highlight): add nix syntax highlighting (#6386) 2025-12-29 15:53:41 -06:00
samcornorandSomair Ansar fd973d242e fix(webfetch): make format parameter optional with markdown default (#6345)
Co-authored-by: Somair Ansar <somairansar@Somairs-MacBook-Air.local>
2025-12-29 15:53:12 -06:00
ja c3d8672753 ci: use env vars for DRY workflow config (#6395) 2025-12-29 15:47:47 -06:00
Shoubhit Dash fe8ef041f6 add supermemory plugin to ecosystem (#6399) 2025-12-29 15:46:22 -06:00
Frank c841de947e zen: add gpt 5.1 codex mini 2025-12-29 16:44:11 -05:00
GitHub Action 825dfd48b1 chore: generate 2025-12-29 21:16:16 +00:00
Graham Bennett 923d114ffa Support different Nix store path prefixes (#6367) 2025-12-29 15:15:42 -06:00
Cole Leavitt b157fd10a7 fix: filter messages with only step-start parts in toModelMessage (#6383) 2025-12-29 14:58:11 -06:00
ja 67ebe68160 feat(format): add nixfmt formatter for Nix files (#6380) 2025-12-29 14:57:52 -06:00
GitHub Action 7b63c14154 chore: generate 2025-12-29 20:50:47 +00:00
Adam cdc11cde2e ignore: hide provider connect button until providers loaded 2025-12-29 14:50:06 -06:00
opencode 9721223b7e release: v1.0.208
Update Nix Hashes / update (push) Has been cancelled
2025-12-29 20:44:22 +00:00
Adam 35a626e711 fix(desktop): don't flash permissions with auto-accept 2025-12-29 14:40:53 -06:00
Adam bb7b0ff221 fix(desktop): scroll sync 2025-12-29 14:36:27 -06:00
Adam 68b4038196 fix(desktop): more performance/scrolling fixes 2025-12-29 14:23:41 -06:00
Adam 3109214900 feat(desktop): auto-accept edits toggle 2025-12-29 14:23:41 -06:00
Adam 86ccc3409b fix(desktop): toast position 2025-12-29 14:23:41 -06:00
Github Action a89089c88f Update Nix flake.lock and hashes 2025-12-29 20:06:20 +00:00
CasualDeveloper e617c5d689 fix: prevent truncated Claude streams (#6388) 2025-12-29 14:04:53 -06:00
Frank 31983ca5ff zen: do not switch provider for models require stick provider 2025-12-29 14:27:51 -05:00
Aiden Cline 59e3b7409f chore: fix type error 2025-12-29 12:38:35 -06:00
Daniel Polito b7ce46f7a1 Desktop: Image Preview and Dedupe File Upload (#6372) 2025-12-29 11:22:48 -06:00
Brett HeapbrettheapClaude Opus 4.5copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
82b8d8fa5d fix(tui): make auth URLs clickable regardless of line wrapping (#6317)
Co-authored-by: brettheap <brett.heap@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2025-12-29 11:21:09 -06:00
Adam 77c837eb1a fix(desktop): throttle markdown renders 2025-12-29 11:19:40 -06:00
Adam db77cc9845 chore: cleanup 2025-12-29 11:19:40 -06:00
Aiden Cline 68043edae6 ci: changelog script update (#6371) 2025-12-29 11:12:25 -06:00
Frainer Encarnación 337681dbbf fix(lsp): ESLint LSP server fails to auto-install on Windows (#6366) 2025-12-29 11:02:46 -06:00
Adam 66afc034d1 fix(desktop): don't show summary when already complete 2025-12-29 10:58:53 -06:00
Adam 11ab8de59f fix(desktop): markdown lists 2025-12-29 10:47:05 -06:00
Adam 5f074edc3a fix(desktop): performance/jankiness 2025-12-29 10:42:48 -06:00
Matt SilverlockandOpenCode 56b5cdf883 feat: install local plugin dependencies from package.json (#6302)
Co-authored-by: OpenCode <opencode@example.com>
2025-12-29 10:37:41 -06:00
Adam fb0e1e4d8d Revert "fix(desktop): jankiness"
This reverts commit 831e9bce51.
2025-12-29 09:56:33 -06:00
Github Action b745b1593f Update Nix flake.lock and hashes 2025-12-29 15:55:48 +00:00
Adam 7376c3f8e7 feat(desktop): latex support 2025-12-29 09:54:22 -06:00
Adam 831e9bce51 fix(desktop): jankiness 2025-12-29 09:47:57 -06:00
Adam 5de73abd82 fix(desktop): markdown styles 2025-12-29 09:47:57 -06:00
Zeno Jiricek 3adbbc1b23 docs: add opencode-skillful plugin to ecosystem page (#6333) 2025-12-29 09:32:44 -06:00
Frank c6c29b3dcf zen: minimax m2.1 2025-12-29 10:16:32 -05:00
Adam a687d7c15f fix(desktop): one permission at a time 2025-12-29 09:07:36 -06:00
Daniel Polito 0c6da69f39 Desktop: Edit Project (#6360) 2025-12-29 08:54:49 -06:00
Adam c4930eb6b2 fix(desktop): more fine-grained state updates for permissions 2025-12-29 08:47:38 -06:00
Frank a24549fce7 docs: update MiniMax console link in integration instructions 2025-12-29 09:29:01 -05:00
Adam c0f9b13630 fix(desktop): more fine-grained state updates 2025-12-29 08:21:32 -06:00
Adam 98fd53fd5f fix(core): preserve imperative statements in summary 2025-12-29 07:25:55 -06:00
Adam 5b02a3029e fix(desktop): max height on edit tool calls 2025-12-29 07:03:44 -06:00
Frank 94e851c2a2 docs: add MiniMax integration instructions to providers documentation 2025-12-29 07:45:54 -05:00
GitHub Action 1658a3ff59 ignore: update download stats 2025-12-29 2025-12-29 12:05:06 +00:00
Adam 9c8bc64138 fix(desktop): sync last agent and model when changing session 2025-12-29 02:57:28 -06:00
Adam 80f704ebbf fix(desktop): context usage alignment 2025-12-29 02:47:51 -06:00
Matt Silverlock 4dae6d1fcf meta: use colors for agents (#5845) 2025-12-28 23:05:17 -06:00
5d2cab39da docs: add compaction, watcher, experimental and provider options (#6304)
Co-authored-by: OpenCode <opencode@example.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-12-28 23:01:43 -06:00
GitHub Action 6963f96d4b chore: generate 2025-12-29 04:56:54 +00:00
Alice Alexandra MooreandAiden Cline 05a9e7ce7a docs: clarify that MCP tools require glob patterns to disable (#6306)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-12-28 22:56:22 -06:00
GitHub Action 896d18ab3f chore: generate 2025-12-29 04:44:48 +00:00
Grégoire MorpainAiden Clinegithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
893888536a fix(bedrock): support region and bearer token configuration (#6332)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-12-28 22:44:15 -06:00
GitHub Action c6221fc8b3 chore: generate 2025-12-29 04:39:43 +00:00
Connor Adams ae67f43ff0 feat: add support for .claude/skills directory (#6252) 2025-12-28 22:39:10 -06:00
opencode 76880dce0d release: v1.0.207
Update Nix Hashes / update (push) Has been cancelled
2025-12-29 01:57:53 +00:00
Adam aafffb5b4b chore: cleanup 2025-12-28 19:54:22 -06:00
Adam a71c9e3f2e fix(desktop): edit diffs 2025-12-28 19:49:39 -06:00
Adam 0156f03e0e chore: cleanup theme stuff 2025-12-28 19:27:36 -06:00
Frank e0bb96a9f9 wip: bench 2025-12-28 20:00:49 -05:00
Daniel Polito 82e5d6d458 Desktop: Sync LSP updates (#6305) 2025-12-28 16:07:36 -06:00
Adam a4411c21b6 feat(desktop): theme preview 2025-12-28 15:47:05 -06:00
Frank 9d61370ac4 sync 2025-12-28 15:33:18 -05:00
Frank f3febd6e39 wip: benchmark 2025-12-28 14:55:05 -05:00
GitHub Action f12d55bf1e chore: generate 2025-12-28 19:13:43 +00:00
Matt SilverlockandOpenCode 0c19b71f42 docs: add plugin configuration documentation (#6301)
Co-authored-by: OpenCode <opencode@example.com>
2025-12-28 13:13:11 -06:00
Mohak S 70fa66397e docs: add opencode-notifier plugin to ecosystem (#6283) 2025-12-28 13:09:38 -06:00
Daniel Polito 6e8cd3174c Include current working directory in local MCP transport (#6303) 2025-12-28 13:09:24 -06:00
GitHub Action 5bfffbe083 chore: generate 2025-12-28 19:06:59 +00:00
Didier Durand 29d8557d41 doc: fix typos in various files (#6294) 2025-12-28 13:06:25 -06:00
Didier Durand ffd20b4477 chore: activate code coverage in bun test config (#6297) 2025-12-28 19:05:55 +00:00
opencode 2abaa46e23 release: v1.0.206
Update Nix Hashes / update (push) Has been cancelled
2025-12-28 19:05:54 +00:00
GitHub Action 0cbbb20d22 chore: generate
publish-vscode / publish (push) Has been cancelled
Update Nix Hashes / update (push) Has been cancelled
2025-12-28 18:54:55 +00:00
Frank 81c5e7b9ed wip: benchmark 2025-12-28 13:54:11 -05:00
opencode ddf4897eaa release: v1.0.205
Update Nix Hashes / update (push) Has been cancelled
2025-12-28 18:37:58 +00:00
Adam 040939fb72 chore: cleanup theme stuff 2025-12-28 10:21:32 -06:00
Adam f89b83a6d7 chore: cleanup theme stuff 2025-12-28 10:14:30 -06:00
Adam 82a876da4d chore: cleanup 2025-12-28 06:41:59 -06:00
GitHub Action 69a15ae9c1 ignore: update download stats 2025-12-28 2025-12-28 12:04:31 +00:00
Adam 18c8e5f451 chore: cleanup 2025-12-28 05:47:22 -06:00
Adam ba3a1cfa0b chore: cleanup 2025-12-28 05:47:21 -06:00
Github Action d8563160f7 Update Nix flake.lock and hashes 2025-12-28 11:13:54 +00:00
Adam 4a9ff9412e feat(desktop): themes 2025-12-28 05:12:36 -06:00
Matt Silverlock d6db6ff198 fix: handle non-text response parts in GitHub action (#6173) 2025-12-27 21:24:10 -06:00
Aiden Cline 79c263494f tweak: inform agent if no skills are available 2025-12-27 21:20:00 -06:00
Adam 1b5bf32ce5 chore: permissions ux 2025-12-27 20:40:25 -06:00
Adam 2e972b3fdc fix(desktop): copy/paste in terminal 2025-12-27 20:18:59 -06:00
Adam d70e9fb01e chore(desktop): cleanup 2025-12-27 19:59:16 -06:00
Adam fc082a0f14 fix(desktop): drag file over entire body to attach 2025-12-27 19:49:35 -06:00
Adam 953e4e9446 chore(desktop): vertical tabs 2025-12-27 19:43:52 -06:00
rektideandAiden Cline 7ea0d37ee3 Thinking & tool call visibility settings for /copy and /export (#6243)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-12-27 19:32:33 -06:00
scarf e35d97f9d7 feat: add bash shell completions (#6239) 2025-12-27 19:14:56 -06:00
GitHub Action 2c0d9a46cb chore: generate 2025-12-28 01:12:02 +00:00
NindalethandBlack_Fox 2fe7a7f2d3 docs: document attach command (#6254)
Co-authored-by: Black_Fox <radekliska@gmail.com>
2025-12-27 19:11:30 -06:00
Connor Adams 8a2f4ddf70 chore: update INVALID_DIRS to include plural 'skills' directory (#6255) 2025-12-27 19:10:51 -06:00
processtraderopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>rekram1-node
7a94d7a2c5 fix: stats command to correctly handle --days 0 for current day statistics (#6259)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2025-12-27 19:10:23 -06:00
Aiden Cline de28fafb47 fix: search all recent models instead of only top 5 in TUI /models command 2025-12-27 19:07:38 -06:00
9d485dd307 docs: add opencode-notificator to ecosystem plugins list (#6269)
Co-authored-by: Ivan Pantic <panta@talentkit.io>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-27 18:54:27 -06:00
GitHub Action 613813ac12 chore: generate 2025-12-28 00:53:48 +00:00
ewired 7617f59441 Allow line numbers and ranges in autocomplete (#4238) 2025-12-27 18:53:17 -06:00
opencode 7aecb43e84 release: v1.0.204
Update Nix Hashes / update (push) Has been cancelled
2025-12-27 20:51:09 +00:00
Adam 21eba5f987 feat(desktop): permissions 2025-12-27 14:43:42 -06:00
Adam c523ca4127 wip(desktop): handle more errors 2025-12-27 14:33:22 -06:00
GitHub Action 685f3ea324 ignore: update download stats 2025-12-27 2025-12-27 12:04:27 +00:00
Aiden Cline 4667d57e3c ci: stale issues 2025-12-27 00:51:05 -06:00
Didier Durand e6b9988fa4 doc: fix typos in various files (#6238) 2025-12-27 00:46:06 -06:00
rari404 3c02d5d338 feat: add path traversal protection to File.read and File.list (#5985) 2025-12-26 23:20:07 -06:00
Christopher Ochsenreither bfb9787361 fix: compact command after revert now properly cleans up revert state (#6235) 2025-12-26 22:57:59 -06:00
ja 1bcc72c477 feat: add ability to disable spinner animation (#6084) 2025-12-26 22:12:35 -06:00
Adam 4385fa4dd7 fix(desktop): prompt input fixes, directory and branch in status bar 2025-12-26 20:47:13 -06:00
Dax Raad 2b054bec95 core: fix compaction config checks to properly respect user settings 2025-12-26 19:48:56 -05:00
Dax Raad 2cdc88d295 core: add compaction config tests to verify auto and prune settings work correctly 2025-12-26 19:44:32 -05:00
GitHub Action f8fb08b3b4 chore: generate 2025-12-27 00:32:34 +00:00
Dax Raad ed06de5e30 core: add configurable compaction settings to allow users to disable auto-compaction and pruning via config instead of flags 2025-12-26 19:31:48 -05:00
Frank 52b99622ad zen: add context for login errors 2025-12-26 17:32:39 -05:00
Github Action a15397cd89 Update Nix flake.lock and hashes 2025-12-26 20:49:06 +00:00
GitHub Action da394439a1 chore: generate 2025-12-26 20:48:30 +00:00
Adam 390b0a79b3 fix(core): mdns global config 2025-12-26 14:47:53 -06:00
Adam b2f45d574f Reapply "feat(core): optional mdns service (#6192)"
This reverts commit 505068d5a6.
2025-12-26 14:47:53 -06:00
Aiden Cline 1e2ef07c97 chore: kill some unused tools 2025-12-26 14:31:22 -06:00
Aiden Cline 664e6bf2d0 test: add more tests to make sure that cwd is locked for read tool 2025-12-26 14:30:05 -06:00
Aiden Cline 160c8ab7cc tweak: bash tool description to avoid unnecessary 'cd &&' usage 2025-12-26 13:44:52 -06:00
Matt Silverlock 1626341a4a github: support issues and workflow_dispatch events (#6157) 2025-12-26 13:34:03 -06:00
Aiden Cline 61ddd1716d ci: re-enable sync zed 2025-12-26 12:24:14 -06:00
Aiden Cline 053a10e515 ci: fix token for gh 2025-12-26 12:22:56 -06:00
Aiden Cline e1c1b1340b ci: fix var 2025-12-26 12:08:16 -06:00
Aiden Cline 7a5fbdf67c ci: update zed extension sync 2025-12-26 12:06:36 -06:00
Github Action 9afc451020 Update Nix flake.lock and hashes 2025-12-26 17:45:58 +00:00
GitHub Action f4fdf0eb03 chore: generate 2025-12-26 17:45:03 +00:00
Aiden Cline 505068d5a6 Revert "feat(core): optional mdns service (#6192)"
This reverts commit 26e7043718.
2025-12-26 11:43:52 -06:00
Aiden Cline 2e10ffac6b chore: rm comments 2025-12-26 11:43:13 -06:00
Aiden Cline 4abaa052db fix: adjust upgrade command to use gh releases page if not npm/bun/pnpm install method 2025-12-26 11:43:12 -06:00
Rohan Godha 1bcf8d8806 fix: opencode web baseURL error (#6181) 2025-12-26 11:36:31 -06:00
Ariane Emory 25c68c8061 chore: kill the dead Polaris Alpha code (#6193) 2025-12-26 11:32:31 -06:00
ja b0e4408ecf feat: add shfmt formatter for shell scripts (#6204) 2025-12-26 11:31:51 -06:00
Aiden Cline 8416db03ef tweak: make install script handle 404s better 2025-12-26 11:28:18 -06:00
Github Action d5b47d9128 Update Nix flake.lock and hashes 2025-12-26 17:09:54 +00:00
GitHub Action 634559760a chore: generate 2025-12-26 17:09:31 +00:00
Ayush Walekar 155ba794cf chore: createOpencodeServer expose logLevel (#6202) 2025-12-26 11:09:06 -06:00
Roberto CarvajalandAiden Cline f1ab427f0e fix(dep): Update package.json - fix perplexity provider version (#6199)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-12-26 11:08:45 -06:00
Daniel PolitoandAdam 2333af6ed3 Desktop: MCP UI (#6162)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2025-12-26 10:49:05 -06:00
GitHub Action 54588b4570 chore: generate 2025-12-26 16:30:20 +00:00
AdamandGithub Action 26e7043718 feat(core): optional mdns service (#6192)
Co-authored-by: Github Action <action@github.com>
2025-12-26 10:29:48 -06:00
GitHub Action dd569c927a chore: generate 2025-12-26 16:22:05 +00:00
Didier Durand cf38884778 doc: fix typos in various files (#6196) 2025-12-26 10:21:33 -06:00
GitHub Action 2946a6d9a7 ignore: update download stats 2025-12-26 2025-12-26 12:10:30 +00:00
Aiden Cline 3522c460e3 tweak: update transform for gemini models so that topP and topK match gemini-cli values 2025-12-25 22:46:12 -06:00
GitHub Action b6a264819e chore: generate 2025-12-26 04:25:19 +00:00
JackNorris 46c7a41d5f fix: only show diagnostics block when errors exist (#6175) 2025-12-25 22:24:48 -06:00
opencode 7cc4b24ac2 release: v1.0.203
Update Nix Hashes / update (push) Has been cancelled
2025-12-26 04:10:11 +00:00
Dax Raad 281ce4c0c3 prompt update to prevent searching via bash tool 2025-12-25 23:07:39 -05:00
Donghyun Shin f59d274d0f fix(lsp): make JDTLS use the correct config directory on Windows (#6121) 2025-12-25 21:17:54 -06:00
GitHub Action 8886c78dce chore: generate 2025-12-26 03:05:15 +00:00
Marco d9f0f58277 feat: haskell lsp support (#6141) 2025-12-25 21:04:43 -06:00
286 changed files with 12074 additions and 2222 deletions
+6 -3
View File
@@ -5,6 +5,9 @@ on:
- cron: "0 */12 * * *" - cron: "0 */12 * * *"
workflow_dispatch: workflow_dispatch:
env:
LOOKBACK_HOURS: 4
jobs: jobs:
update-docs: update-docs:
if: github.repository == 'sst/opencode' if: github.repository == 'sst/opencode'
@@ -25,9 +28,9 @@ jobs:
- name: Get recent commits - name: Get recent commits
id: commits id: commits
run: | run: |
COMMITS=$(git log --since="4 hours ago" --pretty=format:"- %h %s" 2>/dev/null || echo "") COMMITS=$(git log --since="${{ env.LOOKBACK_HOURS }} hours ago" --pretty=format:"- %h %s" 2>/dev/null || echo "")
if [ -z "$COMMITS" ]; then if [ -z "$COMMITS" ]; then
echo "No commits in the last 4 hours" echo "No commits in the last ${{ env.LOOKBACK_HOURS }} hours"
echo "has_commits=false" >> $GITHUB_OUTPUT echo "has_commits=false" >> $GITHUB_OUTPUT
else else
echo "has_commits=true" >> $GITHUB_OUTPUT echo "has_commits=true" >> $GITHUB_OUTPUT
@@ -47,7 +50,7 @@ jobs:
model: opencode/gpt-5.2 model: opencode/gpt-5.2
agent: docs agent: docs
prompt: | prompt: |
Review the following commits from the last 4 hours and identify any new features that may need documentation. Review the following commits from the last ${{ env.LOOKBACK_HOURS }} hours and identify any new features that may need documentation.
<recent_commits> <recent_commits>
${{ steps.commits.outputs.list }} ${{ steps.commits.outputs.list }}
+1 -1
View File
@@ -64,7 +64,7 @@ jobs:
Please check all the code changes in this pull request against the style guide, also look for any bugs if they exist. Diffs are important but make sure you read the entire file to get proper context. Make it clear the suggestions are merely suggestions and the human can decide what to do Please check all the code changes in this pull request against the style guide, also look for any bugs if they exist. Diffs are important but make sure you read the entire file to get proper context. Make it clear the suggestions are merely suggestions and the human can decide what to do
When critiquing code against the style guide, be sure that the code is ACTUALLY in violation, don't complain about else statements if they already use early returns there. You may complain about excessive nesting though, regardless of else statement usage. When critiquing code against the style guide, be sure that the code is ACTUALLY in violation, don't complain about else statements if they already use early returns there. You may complain about excessive nesting though, regardless of else statement usage.
When critiquing code style don't be a zealot, we don't like "let" statements but sometimes they are the simpliest option, if someone does a bunch of nesting with let, they should consider using iife (see packages/opencode/src/util.iife.ts) When critiquing code style don't be a zealot, we don't like "let" statements but sometimes they are the simplest option, if someone does a bunch of nesting with let, they should consider using iife (see packages/opencode/src/util.iife.ts)
Use the gh cli to create comments on the files for the violations. Try to leave the comment on the exact line number. If you have a suggested fix include it in a suggestion code block. Use the gh cli to create comments on the files for the violations. Try to leave the comment on the exact line number. If you have a suggested fix include it in a suggestion code block.
If you are writing suggested fixes, BE SURE THAT the change you are recommending is actually valid typescript, often I have seen missing closing "}" or other syntax errors. If you are writing suggested fixes, BE SURE THAT the change you are recommending is actually valid typescript, often I have seen missing closing "}" or other syntax errors.
+33
View File
@@ -0,0 +1,33 @@
name: "Auto-close stale issues"
on:
schedule:
- cron: "30 1 * * *" # Daily at 1:30 AM
workflow_dispatch:
env:
DAYS_BEFORE_STALE: 90
DAYS_BEFORE_CLOSE: 7
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/stale@v10
with:
days-before-stale: ${{ env.DAYS_BEFORE_STALE }}
days-before-close: ${{ env.DAYS_BEFORE_CLOSE }}
stale-issue-label: "stale"
close-issue-message: |
[automated] Closing due to ${{ env.DAYS_BEFORE_STALE }}+ days of inactivity.
Feel free to reopen if you still need this!
stale-issue-message: |
[automated] This issue has had no activity for ${{ env.DAYS_BEFORE_STALE }} days.
It will be closed in ${{ env.DAYS_BEFORE_CLOSE }} days if there's no new activity.
remove-stale-when-updated: true
exempt-issue-labels: "pinned,security,feature-request,on-hold"
start-date: "2025-12-27"
+4 -3
View File
@@ -2,8 +2,8 @@ name: "sync-zed-extension"
on: on:
workflow_dispatch: workflow_dispatch:
# release: release:
# types: [published] types: [published]
jobs: jobs:
zed: zed:
@@ -31,4 +31,5 @@ jobs:
run: | run: |
./script/sync-zed.ts ${{ steps.get_tag.outputs.tag }} ./script/sync-zed.ts ${{ steps.get_tag.outputs.tag }}
env: env:
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }} ZED_EXTENSIONS_PAT: ${{ secrets.ZED_EXTENSIONS_PAT }}
ZED_PR_PAT: ${{ secrets.ZED_PR_PAT }}
+4
View File
@@ -20,3 +20,7 @@ opencode.json
a.out a.out
target target
.scripts .scripts
# Local dev files
opencode-dev
logs/
+1
View File
@@ -1,5 +1,6 @@
--- ---
description: ALWAYS use this when writing docs description: ALWAYS use this when writing docs
color: "#38A3EE"
--- ---
You are an expert technical documentation writer You are an expert technical documentation writer
+1
View File
@@ -2,6 +2,7 @@
mode: primary mode: primary
hidden: true hidden: true
model: opencode/claude-haiku-4-5 model: opencode/claude-haiku-4-5
color: "#44BA81"
tools: tools:
"*": false "*": false
"github-triage": true "github-triage": true
+30
View File
@@ -34,6 +34,36 @@ Want to take on an issue? Leave a comment and a maintainer may assign it to you
bun dev bun dev
``` ```
### Running against a different directory
By default, `bun dev` runs OpenCode in the `packages/opencode` directory. To run it against a different directory or repository:
```bash
bun dev <directory>
```
To run OpenCode in the root of the opencode repo itself:
```bash
bun dev .
```
### Building a "localcode"
To compile a standalone executable:
```bash
./packages/opencode/script/build.ts --single
```
Then run it with:
```bash
./packages/opencode/dist/opencode-<platform>/bin/opencode
```
Replace `<platform>` with your platform (e.g., `darwin-arm64`, `linux-x64`).
- Core pieces: - Core pieces:
- `packages/opencode`: OpenCode core business logic & server. - `packages/opencode`: OpenCode core business logic & server.
- `packages/opencode/src/cli/cmd/tui/`: The TUI code, written in SolidJS with [opentui](https://github.com/sst/opentui) - `packages/opencode/src/cli/cmd/tui/`: The TUI code, written in SolidJS with [opentui](https://github.com/sst/opentui)
+2 -2
View File
@@ -79,7 +79,7 @@ you can switch between these using the `Tab` key.
- Asks permission before running bash commands - Asks permission before running bash commands
- Ideal for exploring unfamiliar codebases or planning changes - Ideal for exploring unfamiliar codebases or planning changes
Also, included is a **general** subagent for complex searches and multi-step tasks. Also, included is a **general** subagent for complex searches and multistep tasks.
This is used internally and can be invoked using `@general` in messages. This is used internally and can be invoked using `@general` in messages.
Learn more about [agents](https://opencode.ai/docs/agents). Learn more about [agents](https://opencode.ai/docs/agents).
@@ -98,7 +98,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
### FAQ ### FAQ
#### How is this different than Claude Code? #### How is this different from Claude Code?
It's very similar to Claude Code in terms of capability. Here are the key differences: It's very similar to Claude Code in terms of capability. Here are the key differences:
+5
View File
@@ -181,3 +181,8 @@
| 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) | | 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) |
| 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) | | 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) |
| 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) | | 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) |
| 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) |
| 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) |
| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) |
| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) |
| 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) |
+69 -69
View File
@@ -5,13 +5,6 @@
"": { "": {
"name": "opencode", "name": "opencode",
"dependencies": { "dependencies": {
"@ai-sdk/cerebras": "1.0.33",
"@ai-sdk/cohere": "2.0.21",
"@ai-sdk/deepinfra": "1.0.30",
"@ai-sdk/gateway": "2.0.23",
"@ai-sdk/groq": "2.0.33",
"@ai-sdk/perplexity": "2.0.22",
"@ai-sdk/togetherai": "1.0.30",
"@aws-sdk/client-s3": "3.933.0", "@aws-sdk/client-s3": "3.933.0",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
@@ -29,7 +22,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.0.202", "version": "1.0.217",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -77,7 +70,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.0.202", "version": "1.0.217",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -105,7 +98,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.0.202", "version": "1.0.217",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -132,7 +125,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.0.202", "version": "1.0.217",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "2.0.0", "@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2", "@ai-sdk/openai": "2.0.2",
@@ -156,7 +149,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.0.202", "version": "1.0.217",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -180,13 +173,14 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.0.202", "version": "1.0.217",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-dialog": "~2",
"@tauri-apps/plugin-http": "~2", "@tauri-apps/plugin-http": "~2",
"@tauri-apps/plugin-notification": "~2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "~2", "@tauri-apps/plugin-os": "~2",
"@tauri-apps/plugin-process": "~2", "@tauri-apps/plugin-process": "~2",
@@ -207,7 +201,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.0.202", "version": "1.0.217",
"dependencies": { "dependencies": {
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -236,7 +230,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.0.202", "version": "1.0.217",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -252,7 +246,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.0.202", "version": "1.0.217",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -261,16 +255,22 @@
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.5.1", "@agentclientprotocol/sdk": "0.5.1",
"@ai-sdk/amazon-bedrock": "3.0.57", "@ai-sdk/amazon-bedrock": "3.0.57",
"@ai-sdk/anthropic": "2.0.50", "@ai-sdk/anthropic": "2.0.56",
"@ai-sdk/azure": "2.0.73", "@ai-sdk/azure": "2.0.82",
"@ai-sdk/google": "2.0.44", "@ai-sdk/cerebras": "1.0.33",
"@ai-sdk/cohere": "2.0.21",
"@ai-sdk/deepinfra": "1.0.30",
"@ai-sdk/gateway": "2.0.23",
"@ai-sdk/google": "2.0.49",
"@ai-sdk/google-vertex": "3.0.81", "@ai-sdk/google-vertex": "3.0.81",
"@ai-sdk/mcp": "0.0.8", "@ai-sdk/groq": "2.0.33",
"@ai-sdk/mistral": "2.0.26", "@ai-sdk/mistral": "2.0.26",
"@ai-sdk/openai": "2.0.71", "@ai-sdk/openai": "2.0.71",
"@ai-sdk/openai-compatible": "1.0.27", "@ai-sdk/openai-compatible": "1.0.29",
"@ai-sdk/perplexity": "2.0.22",
"@ai-sdk/provider": "2.0.0", "@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.18", "@ai-sdk/provider-utils": "3.0.19",
"@ai-sdk/togetherai": "1.0.30",
"@ai-sdk/xai": "2.0.42", "@ai-sdk/xai": "2.0.42",
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
@@ -284,14 +284,15 @@
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2", "@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.63", "@opentui/core": "0.1.67",
"@opentui/solid": "0.1.63", "@opentui/solid": "0.1.67",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
"@standard-schema/spec": "1.0.0", "@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62", "@zip.js/zip.js": "2.7.62",
"ai": "catalog:", "ai": "catalog:",
"bonjour-service": "1.3.0",
"bun-pty": "0.4.2", "bun-pty": "0.4.2",
"chokidar": "4.0.3", "chokidar": "4.0.3",
"clipboardy": "4.0.0", "clipboardy": "4.0.0",
@@ -346,7 +347,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.0.202", "version": "1.0.217",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"zod": "catalog:", "zod": "catalog:",
@@ -366,7 +367,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.0.202", "version": "1.0.217",
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "0.88.1", "@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
@@ -377,7 +378,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.0.202", "version": "1.0.217",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -390,7 +391,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.0.202", "version": "1.0.217",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -402,8 +403,10 @@
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"fuzzysort": "catalog:", "fuzzysort": "catalog:",
"katex": "0.16.27",
"luxon": "catalog:", "luxon": "catalog:",
"marked": "catalog:", "marked": "catalog:",
"marked-katex-extension": "5.1.6",
"marked-shiki": "catalog:", "marked-shiki": "catalog:",
"remeda": "catalog:", "remeda": "catalog:",
"shiki": "catalog:", "shiki": "catalog:",
@@ -415,6 +418,7 @@
"@tailwindcss/vite": "catalog:", "@tailwindcss/vite": "catalog:",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/katex": "0.16.7",
"@types/luxon": "catalog:", "@types/luxon": "catalog:",
"tailwindcss": "catalog:", "tailwindcss": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",
@@ -425,7 +429,7 @@
}, },
"packages/util": { "packages/util": {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.0.202", "version": "1.0.217",
"dependencies": { "dependencies": {
"zod": "catalog:", "zod": "catalog:",
}, },
@@ -436,7 +440,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.0.202", "version": "1.0.217",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -537,7 +541,7 @@
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w=="], "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w=="],
"@ai-sdk/azure": ["@ai-sdk/azure@2.0.73", "", { "dependencies": { "@ai-sdk/openai": "2.0.71", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LpAg3Ak/V3WOemBu35Qbx9jfQfApsHNXX9p3bXVsnRu3XXi1QQUt5gMOCIb4znPonz+XnHenIDZMBwdsb1TfRQ=="], "@ai-sdk/azure": ["@ai-sdk/azure@2.0.82", "", { "dependencies": { "@ai-sdk/openai": "2.0.80", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Bpab51ETBB4adZC1xGMYsryL/CB8j1sA+t5aDqhRv3t3WRLTxhaBDcFKtQTIuxiEQTFosz9Q2xQqdfBvQm5jHw=="],
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.29", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2gSSS/7kunIwMdC4td5oWsUAzoLw84ccGpz6wQbxVnrb1iWnrEnKa5tRBduaP6IXpzLWsu8wME3+dQhZy+gT7w=="], "@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.29", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2gSSS/7kunIwMdC4td5oWsUAzoLw84ccGpz6wQbxVnrb1iWnrEnKa5tRBduaP6IXpzLWsu8wME3+dQhZy+gT7w=="],
@@ -547,14 +551,12 @@
"@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.23", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-qmX7afPRszUqG5hryHF3UN8ITPIRSGmDW6VYCmByzjoUkgm3MekzSx2hMV1wr0P+llDeuXb378SjqUfpvWJulg=="], "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.23", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-qmX7afPRszUqG5hryHF3UN8ITPIRSGmDW6VYCmByzjoUkgm3MekzSx2hMV1wr0P+llDeuXb378SjqUfpvWJulg=="],
"@ai-sdk/google": ["@ai-sdk/google@2.0.44", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-c5dck36FjqiVoeeMJQLTEmUheoURcGTU/nBT6iJu8/nZiKFT/y8pD85KMDRB7RerRYaaQOtslR2d6/5PditiRw=="], "@ai-sdk/google": ["@ai-sdk/google@2.0.49", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-efwKk4mOV0SpumUaQskeYABk37FJPmEYwoDJQEjyLRmGSjtHRe9P5Cwof5ffLvaFav2IaJpBGEz98pyTs7oNWA=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.81", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.50", "@ai-sdk/google": "2.0.44", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18", "google-auth-library": "^9.15.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yrl5Ug0Mqwo9ya45oxczgy2RWgpEA/XQQCSFYP+3NZMQ4yA3Iim1vkOjVCsGaZZ8rjVk395abi1ZMZV0/6rqVA=="], "@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.81", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.50", "@ai-sdk/google": "2.0.44", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18", "google-auth-library": "^9.15.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yrl5Ug0Mqwo9ya45oxczgy2RWgpEA/XQQCSFYP+3NZMQ4yA3Iim1vkOjVCsGaZZ8rjVk395abi1ZMZV0/6rqVA=="],
"@ai-sdk/groq": ["@ai-sdk/groq@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FWGl7xNr88NBveao3y9EcVWYUt9ABPrwLFY7pIutSNgaTf32vgvyhREobaMrLU4Scr5G/2tlNqOPZ5wkYMaZig=="], "@ai-sdk/groq": ["@ai-sdk/groq@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FWGl7xNr88NBveao3y9EcVWYUt9ABPrwLFY7pIutSNgaTf32vgvyhREobaMrLU4Scr5G/2tlNqOPZ5wkYMaZig=="],
"@ai-sdk/mcp": ["@ai-sdk/mcp@0.0.8", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17", "pkce-challenge": "^5.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9y9GuGcZ9/+pMIHfpOCJgZVp+AZMv6TkjX2NVT17SQZvTF2N8LXuCXyoUPyi1PxIxzxl0n463LxxaB2O6olC+Q=="],
"@ai-sdk/mistral": ["@ai-sdk/mistral@2.0.26", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jxDB++4WI1wEx5ONNBI+VbkmYJOYIuS8UQY13/83UGRaiW7oB/WHiH4ETe6KzbKpQPB3XruwTJQjUMsMfKyTXA=="], "@ai-sdk/mistral": ["@ai-sdk/mistral@2.0.26", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jxDB++4WI1wEx5ONNBI+VbkmYJOYIuS8UQY13/83UGRaiW7oB/WHiH4ETe6KzbKpQPB3XruwTJQjUMsMfKyTXA=="],
"@ai-sdk/openai": ["@ai-sdk/openai@2.0.2", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-D4zYz2uR90aooKQvX1XnS00Z7PkbrcY+snUvPfm5bCabTG7bzLrVtD56nJ5bSaZG8lmuOMfXpyiEEArYLyWPpw=="], "@ai-sdk/openai": ["@ai-sdk/openai@2.0.2", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-D4zYz2uR90aooKQvX1XnS00Z7PkbrcY+snUvPfm5bCabTG7bzLrVtD56nJ5bSaZG8lmuOMfXpyiEEArYLyWPpw=="],
@@ -565,7 +567,7 @@
"@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], "@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.18", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ=="], "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.30", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.29", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9bxQbIXnWSN4bNismrza3NvIo+ui/Y3pj3UN6e9vCszCWFCN45RgISi4oDe10RqmzaJ/X8cfO/Tem+K8MT3wGQ=="], "@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.30", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.29", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9bxQbIXnWSN4bNismrza3NvIo+ui/Y3pj3UN6e9vCszCWFCN45RgISi4oDe10RqmzaJ/X8cfO/Tem+K8MT3wGQ=="],
@@ -1081,6 +1083,8 @@
"@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
"@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="],
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="],
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
@@ -1189,21 +1193,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.63", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.63", "@opentui/core-darwin-x64": "0.1.63", "@opentui/core-linux-arm64": "0.1.63", "@opentui/core-linux-x64": "0.1.63", "@opentui/core-win32-arm64": "0.1.63", "@opentui/core-win32-x64": "0.1.63", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-m4xZQTNCnHXWUWCnGvacJ3Gts1H2aMwP5V/puAG77SDb51jm4W/QOyqAAdgeSakkb9II+8FfUpApX7sfwRXPUg=="], "@opentui/core": ["@opentui/core@0.1.67", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.67", "@opentui/core-darwin-x64": "0.1.67", "@opentui/core-linux-arm64": "0.1.67", "@opentui/core-linux-x64": "0.1.67", "@opentui/core-win32-arm64": "0.1.67", "@opentui/core-win32-x64": "0.1.67", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-zmfyA10QUbzT6ohacPoHmGiYzuJrDSCfQWRWrKtao0BrHj9bii73qWy3V/eR4ibVueoRREwxJs5GlBOSvK6IoA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.63", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jKCThZGiiublKkP/hMtDtl1MLCw5NU0hMNJdEYvz1WLT9bzliWf6Kb7MIDAmk32XlbQW8/RHdp+hGyGDXK62OQ=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.67", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LtOcTlFD+kO7neItmkiF77H8cnjTYzBOZe8JQGwRSt9aaCke3UzMvLxmQnj4BP/kPC3hi9V6NRnFdptz0sJZIQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.63", "", { "os": "darwin", "cpu": "x64" }, "sha512-rfNxynHzJpxN9i+SAMnn1NToEc8rYj64BsOxY78JNsm4Gg1Js1uyMaawwh2WbdGknFy4cDXS9QwkUMdMcfnjiw=="], "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.67", "", { "os": "darwin", "cpu": "x64" }, "sha512-9i+awVWgpEVqZhFLaLq8usNGyCiyT5QxMLy6eH7JmRic79S34u23HfxiniGRtdYh3aqpm9SbLzo60v0nRIUkCA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.63", "", { "os": "linux", "cpu": "arm64" }, "sha512-wG9d6mHWWKZGrzxYS4c+BrcEGXBv/MYBUPSyjP/lD0CxT+X3h6CYhI317JkRyMNfh3vI9CpAKGFTOFvrTTHimQ=="], "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.67", "", { "os": "linux", "cpu": "arm64" }, "sha512-WLjnTM3Ig//SRo0FUZYZJ5TITVbR6dKDVg6axU2D+sMoUzJMBP/Xo04q/TvZ3wP764Yca9l7oVMKWDxHlygyjQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.63", "", { "os": "linux", "cpu": "x64" }, "sha512-TKSzFv4BgWW3RB/iZmq5qxTR4/tRaXo8IZNnVR+LFzShbPOqhUi466AByy9SUmCxD8uYjmMDFYfKtkCy0AnAwA=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.67", "", { "os": "linux", "cpu": "x64" }, "sha512-5UbZ/TqWi/DAmHIZL4NvhdpgTwglszRiddkRiQ8cT0IbnE4lutd4XxWUWcLKwsNT1YJv32TtcGWkuthluLiriQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.63", "", { "os": "win32", "cpu": "arm64" }, "sha512-CBWPyPognERP0Mq4eC1q01Ado2C2WU+BLTgMdhyt+E2P4w8rPhJ2kCt2MNxO66vQUiynspmZkgjQr0II/VjxWA=="], "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.67", "", { "os": "win32", "cpu": "arm64" }, "sha512-KNam5rObhN8/U9+GVVuvtAlGXp3MfdMHnw4W2P6YH7xp8HTsLvABUT91SJEyJ/ktVe9e1itLDG2fDHSoA5NbUg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.63", "", { "os": "win32", "cpu": "x64" }, "sha512-qEp6h//FrT+TQiiHm87wZWUwqTPTqIy1ZD+8R+VCUK+usoQiOAD2SqrYnM7W8JkCMGn5/TKm/GaKLyx/qlK4VA=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.67", "", { "os": "win32", "cpu": "x64" }, "sha512-740lkOw42zLNh9YfahXjCwV2DS/amH2uMDh3tCADDCLckrMhemIhqArXDiMlalDxDqYspoaZCpBsFVsG9dMS6A=="],
"@opentui/solid": ["@opentui/solid@0.1.63", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.63", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-Gccln4qRucAoaoQEZ4NPAHvGmVYzU/8aKCLG8EPgwCKTcpUzlqYt4357cDHq4cnCNOcXOC06hTz/0pK9r0dqXA=="], "@opentui/solid": ["@opentui/solid@0.1.67", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.67", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-dVNq0+PJIdNb63D0T7vcbyVF/ZvLCihGvivTU50zDOzd0Sk5prbrIfpG8+DjMErFubXfdZQvdy/PqFdtw0rjtQ=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1703,6 +1707,8 @@
"@tauri-apps/plugin-http": ["@tauri-apps/plugin-http@2.5.4", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-/i4U/9za3mrytTgfRn5RHneKubZE/dwRmshYwyMvNRlkWjvu1m4Ma72kcbVJMZFGXpkbl+qLyWMGrihtWB76Zg=="], "@tauri-apps/plugin-http": ["@tauri-apps/plugin-http@2.5.4", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-/i4U/9za3mrytTgfRn5RHneKubZE/dwRmshYwyMvNRlkWjvu1m4Ma72kcbVJMZFGXpkbl+qLyWMGrihtWB76Zg=="],
"@tauri-apps/plugin-notification": ["@tauri-apps/plugin-notification@2.3.3", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg=="],
"@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew=="], "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.5.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew=="],
"@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="], "@tauri-apps/plugin-os": ["@tauri-apps/plugin-os@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="],
@@ -1771,6 +1777,8 @@
"@types/jsonwebtoken": ["@types/jsonwebtoken@8.5.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg=="], "@types/jsonwebtoken": ["@types/jsonwebtoken@8.5.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-272FMnFGzAVMGtu9tkr29hRL6bZj4Zs1KZNeHLnKqAvp06tAIcarTMwOh8/8bz4FmKRcMxZhZNeUAQsNLoiPhg=="],
"@types/katex": ["@types/katex@0.16.7", "", {}, "sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ=="],
"@types/luxon": ["@types/luxon@3.7.1", "", {}, "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg=="], "@types/luxon": ["@types/luxon@3.7.1", "", {}, "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
@@ -2003,6 +2011,8 @@
"body-parser": ["body-parser@1.20.3", "", { "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g=="], "body-parser": ["body-parser@1.20.3", "", { "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g=="],
"bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
"bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="],
@@ -2247,6 +2257,8 @@
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"dns-packet": ["dns-packet@5.6.1", "", { "dependencies": { "@leichtgewicht/ip-codec": "^2.0.1" } }, "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
@@ -2779,6 +2791,8 @@
"jwt-decode": ["jwt-decode@3.1.2", "", {}, "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="], "jwt-decode": ["jwt-decode@3.1.2", "", {}, "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="],
"katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="],
"kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="], "kind-of": ["kind-of@6.0.3", "", {}, "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
@@ -2869,6 +2883,8 @@
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"marked-katex-extension": ["marked-katex-extension@5.1.6", "", { "peerDependencies": { "katex": ">=0.16 <0.17", "marked": ">=4 <18" } }, "sha512-vYpLXwmlIDKILIhJtiRTgdyZRn5sEYdFBuTmbpjD7lbCIzg0/DWyK3HXIntN3Tp8zV6hvOUgpZNLWRCgWVc24A=="],
"marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="], "marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -3023,6 +3039,8 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"multicast-dns": ["multicast-dns@7.2.5", "", { "dependencies": { "dns-packet": "^5.2.2", "thunky": "^1.0.2" }, "bin": { "multicast-dns": "cli.js" } }, "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg=="],
"mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="],
"mysql2": ["mysql2@3.14.4", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-Cs/jx3WZPNrYHVz+Iunp9ziahaG5uFMvD2R8Zlmc194AqXNxt9HBNu7ZsPYrUtmJsF0egETCWIdMIYAwOGjL1w=="], "mysql2": ["mysql2@3.14.4", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-Cs/jx3WZPNrYHVz+Iunp9ziahaG5uFMvD2R8Zlmc194AqXNxt9HBNu7ZsPYrUtmJsF0egETCWIdMIYAwOGjL1w=="],
@@ -3595,6 +3613,8 @@
"three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="],
"thunky": ["thunky@1.1.0", "", {}, "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="],
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
@@ -3881,44 +3901,24 @@
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="], "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@2.0.71", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tg+gj+R0z/On9P4V7hy7/7o04cQPjKGayMCL3gzWD/aNGjAKkhEnaocuNDidSnghizt8g2zJn16cAuAolnW+qQ=="], "@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@2.0.80", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tNHuraF11db+8xJEDBoU9E3vMcpnHFKRhnLQ3DQX2LnEzfPB9DksZ8rE+yVuDN1WRW9cm2OWAhgHFgVKs7ICuw=="],
"@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="],
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="], "@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
"@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"@ai-sdk/cohere/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="], "@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
"@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.50", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-21PaHfoLmouOXXNINTsZJsMw+wE5oLR2He/1kq/sKokTVKyq7ObGT1LDk6ahwxaz/GoaNaGankMh+EgVcdv2Cw=="], "@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.50", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-21PaHfoLmouOXXNINTsZJsMw+wE5oLR2He/1kq/sKokTVKyq7ObGT1LDk6ahwxaz/GoaNaGankMh+EgVcdv2Cw=="],
"@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], "@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.18", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ=="],
"@ai-sdk/mcp/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="],
"@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="], "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="], "@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
"@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="], "@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
"@ai-sdk/togetherai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="], "@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
"@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"@astrojs/cloudflare/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], "@astrojs/cloudflare/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
"@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="], "@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="],
@@ -4273,6 +4273,8 @@
"jsonwebtoken/jws": ["jws@3.2.2", "", { "dependencies": { "jwa": "^1.4.1", "safe-buffer": "^5.0.1" } }, "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA=="], "jsonwebtoken/jws": ["jws@3.2.2", "", { "dependencies": { "jwa": "^1.4.1", "safe-buffer": "^5.0.1" } }, "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA=="],
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -4297,11 +4299,11 @@
"nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], "nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.50", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-21PaHfoLmouOXXNINTsZJsMw+wE5oLR2He/1kq/sKokTVKyq7ObGT1LDk6ahwxaz/GoaNaGankMh+EgVcdv2Cw=="], "opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@2.0.71", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tg+gj+R0z/On9P4V7hy7/7o04cQPjKGayMCL3gzWD/aNGjAKkhEnaocuNDidSnghizt8g2zJn16cAuAolnW+qQ=="], "opencode/@ai-sdk/openai": ["@ai-sdk/openai@2.0.71", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tg+gj+R0z/On9P4V7hy7/7o04cQPjKGayMCL3gzWD/aNGjAKkhEnaocuNDidSnghizt8g2zJn16cAuAolnW+qQ=="],
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.27", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bpYruxVLhrTbVH6CCq48zMJNeHu6FmHtEedl9FXckEgcIEAi036idFhJlcRwC1jNCwlacbzb8dPD7OAH1EKJaQ=="], "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="], "opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
@@ -4905,8 +4907,6 @@
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="], "opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="],
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="],
"opencontrol/@modelcontextprotocol/sdk/express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="], "opencontrol/@modelcontextprotocol/sdk/express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="],
"opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="], "opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="],
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1766532406, "lastModified": 1767026758,
"narHash": "sha256-acLU/ag9VEoKkzOD202QASX25nG1eArXg5A0mHjKgxM=", "narHash": "sha256-7fsac/f7nh/VaKJ/qm3I338+wAJa/3J57cOGpXi0Sbg=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "8142186f001295e5a3239f485c8a49bf2de2695a", "rev": "346dd96ad74dc4457a9db9de4f4f57dab2e5731d",
"type": "github" "type": "github"
}, },
"original": { "original": {
+3 -3
View File
@@ -17,7 +17,7 @@
"aarch64-darwin" "aarch64-darwin"
"x86_64-darwin" "x86_64-darwin"
]; ];
lib = nixpkgs.lib; inherit (nixpkgs) lib;
forEachSystem = lib.genAttrs systems; forEachSystem = lib.genAttrs systems;
pkgsFor = system: nixpkgs.legacyPackages.${system}; pkgsFor = system: nixpkgs.legacyPackages.${system};
packageJson = builtins.fromJSON (builtins.readFile ./packages/opencode/package.json); packageJson = builtins.fromJSON (builtins.readFile ./packages/opencode/package.json);
@@ -70,12 +70,12 @@
in in
{ {
default = mkPackage { default = mkPackage {
version = packageJson.version; inherit (packageJson) version;
src = ./.; src = ./.;
scripts = ./nix/scripts; scripts = ./nix/scripts;
target = bunTarget.${system}; target = bunTarget.${system};
modelsDev = "${modelsDev.${system}}/dist/_api.json"; modelsDev = "${modelsDev.${system}}/dist/_api.json";
mkNodeModules = mkNodeModules; inherit mkNodeModules;
}; };
} }
); );
+10
View File
@@ -155,8 +155,18 @@ if [ -z "$requested_version" ]; then
exit 1 exit 1
fi fi
else else
# Strip leading 'v' if present
requested_version="${requested_version#v}"
url="https://github.com/sst/opencode/releases/download/v${requested_version}/$filename" url="https://github.com/sst/opencode/releases/download/v${requested_version}/$filename"
specific_version=$requested_version specific_version=$requested_version
# Verify the release exists before downloading
http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/sst/opencode/releases/tag/v${requested_version}")
if [ "$http_status" = "404" ]; then
echo -e "${RED}Error: Release v${requested_version} not found${NC}"
echo -e "${MUTED}Available releases: https://github.com/sst/opencode/releases${NC}"
exit 1
fi
fi fi
print_message() { print_message() {
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"nodeModules": "sha256-hotsyeWJA6/dP6DvZTN1Ak2RSKcsyvXlXPI/jexBHME=" "nodeModules": "sha256-2Wbnxy9SPcZkO03Sis3uiypPXa87jc5TzKbo6PvMlxY="
} }
+18 -10
View File
@@ -1,18 +1,26 @@
{ hash, lib, stdenvNoCC, bun, cacert, curl }: {
hash,
lib,
stdenvNoCC,
bun,
cacert,
curl,
}:
args: args:
stdenvNoCC.mkDerivation { stdenvNoCC.mkDerivation {
pname = "opencode-node_modules"; pname = "opencode-node_modules";
version = args.version; inherit (args) version src;
src = args.src;
impureEnvVars = impureEnvVars = lib.fetchers.proxyImpureEnvVars ++ [
lib.fetchers.proxyImpureEnvVars "GIT_PROXY_COMMAND"
++ [ "SOCKS_SERVER"
"GIT_PROXY_COMMAND" ];
"SOCKS_SERVER"
];
nativeBuildInputs = [ bun cacert curl ]; nativeBuildInputs = [
bun
cacert
curl
];
dontConfigure = true; dontConfigure = true;
+10 -7
View File
@@ -1,7 +1,13 @@
{ lib, stdenvNoCC, bun, ripgrep, makeBinaryWrapper }: {
lib,
stdenvNoCC,
bun,
ripgrep,
makeBinaryWrapper,
}:
args: args:
let let
scripts = args.scripts; inherit (args) scripts;
mkModules = mkModules =
attrs: attrs:
args.mkNodeModules ( args.mkNodeModules (
@@ -14,13 +20,10 @@ let
in in
stdenvNoCC.mkDerivation (finalAttrs: { stdenvNoCC.mkDerivation (finalAttrs: {
pname = "opencode"; pname = "opencode";
version = args.version; inherit (args) version src;
src = args.src;
node_modules = mkModules { node_modules = mkModules {
version = finalAttrs.version; inherit (finalAttrs) version src;
src = finalAttrs.src;
}; };
nativeBuildInputs = [ nativeBuildInputs = [
+7 -3
View File
@@ -31,9 +31,13 @@ for (const [name, wasmPath] of byName) {
next = next.replaceAll("tree-sitter.wasm", mainWasm).replaceAll("web-tree-sitter/tree-sitter.wasm", mainWasm) next = next.replaceAll("tree-sitter.wasm", mainWasm).replaceAll("web-tree-sitter/tree-sitter.wasm", mainWasm)
// Collapse any relative prefixes before absolute store paths (e.g., "../../../..//nix/store/...") // Collapse any relative prefixes before absolute store paths (e.g., "../../../..//nix/store/...")
const nixStorePrefix = process.env.NIX_STORE || "/nix/store"
next = next.replace(/(\.\/)+/g, "./") next = next.replace(/(\.\/)+/g, "./")
next = next.replace(/(\.\.\/)+\/?(\/nix\/store[^"']+)/g, "/$2") next = next.replace(
next = next.replace(/(["'])\/{2,}(\/nix\/store[^"']+)(["'])/g, "$1/$2$3") new RegExp(`(\\.\\.\\/)+\\/{1,2}(${nixStorePrefix.replace(/^\//, "").replace(/\//g, "\\/")}[^"']+)`, "g"),
next = next.replace(/(["'])\/\/(nix\/store[^"']+)(["'])/g, "$1/$2$3") "/$2",
)
next = next.replace(new RegExp(`(["'])\\/{2,}(\\/${nixStorePrefix.replace(/\//g, "\\/")}[^"']+)(["'])`, "g"), "$1$2$3")
next = next.replace(new RegExp(`(["'])\\/\\/(${nixStorePrefix.replace(/\//g, "\\/")}[^"']+)(["'])`, "g"), "$1$2$3")
if (next !== content) fs.writeFileSync(file, next) if (next !== content) fs.writeFileSync(file, next)
-7
View File
@@ -67,13 +67,6 @@
"turbo": "2.5.6" "turbo": "2.5.6"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/cerebras": "1.0.33",
"@ai-sdk/cohere": "2.0.21",
"@ai-sdk/deepinfra": "1.0.30",
"@ai-sdk/gateway": "2.0.23",
"@ai-sdk/groq": "2.0.33",
"@ai-sdk/perplexity": "2.0.22",
"@ai-sdk/togetherai": "1.0.30",
"@aws-sdk/client-s3": "3.933.0", "@aws-sdk/client-s3": "3.933.0",
"@opencode-ai/plugin": "workspace:*", "@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*", "@opencode-ai/script": "workspace:*",
+31 -6
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" style="background-color: var(--background-base)">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -13,14 +13,39 @@
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta property="og:image" content="/social-share.png" /> <meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" /> <meta property="twitter:image" content="/social-share.png" />
</head> <!-- Theme preload script - applies cached theme to avoid FOUC -->
<body class="antialiased overscroll-none text-12-regular overflow-hidden"> <script id="oc-theme-preload-script">
<script>
;(function () { ;(function () {
const savedTheme = localStorage.getItem("theme") || "oc-1" var themeId = localStorage.getItem("opencode-theme-id")
document.documentElement.setAttribute("data-theme", savedTheme) if (!themeId) return
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
var mode = isDark ? "dark" : "light"
document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode
if (themeId === "oc-1") return
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
if (css) {
var style = document.createElement("style")
style.id = "oc-theme-preload"
style.textContent =
":root{color-scheme:" +
mode +
";--text-mix-blend-mode:" +
(isDark ? "plus-lighter" : "multiply") +
";" +
css +
"}"
document.head.appendChild(style)
}
})() })()
</script> </script>
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-screen"></div> <div id="root" class="flex flex-col h-screen"></div>
<script src="/src/entry.tsx" type="module"></script> <script src="/src/entry.tsx" type="module"></script>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.0.202", "version": "1.0.217",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
+62 -45
View File
@@ -1,5 +1,5 @@
import "@/index.css" import "@/index.css"
import { ErrorBoundary, Show } from "solid-js" import { ErrorBoundary, Show, type ParentProps } from "solid-js"
import { Router, Route, Navigate } from "@solidjs/router" import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font" import { Font } from "@opencode-ai/ui/font"
@@ -8,9 +8,11 @@ import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { CodeComponentProvider } from "@opencode-ai/ui/context/code" import { CodeComponentProvider } from "@opencode-ai/ui/context/code"
import { Diff } from "@opencode-ai/ui/diff" import { Diff } from "@opencode-ai/ui/diff"
import { Code } from "@opencode-ai/ui/code" import { Code } from "@opencode-ai/ui/code"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { GlobalSyncProvider } from "@/context/global-sync" import { GlobalSyncProvider } from "@/context/global-sync"
import { LayoutProvider } from "@/context/layout" import { LayoutProvider } from "@/context/layout"
import { GlobalSDKProvider } from "@/context/global-sdk" import { GlobalSDKProvider } from "@/context/global-sdk"
import { ServerProvider, useServer } from "@/context/server"
import { TerminalProvider } from "@/context/terminal" import { TerminalProvider } from "@/context/terminal"
import { PromptProvider } from "@/context/prompt" import { PromptProvider } from "@/context/prompt"
import { NotificationProvider } from "@/context/notification" import { NotificationProvider } from "@/context/notification"
@@ -29,7 +31,7 @@ declare global {
} }
} }
const url = iife(() => { const defaultServerUrl = iife(() => {
const param = new URLSearchParams(document.location.search).get("url") const param = new URLSearchParams(document.location.search).get("url")
if (param) return param if (param) return param
@@ -38,55 +40,70 @@ const url = iife(() => {
if (import.meta.env.DEV) if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return "http://localhost:4096" return window.location.origin
}) })
function ServerKey(props: ParentProps) {
const server = useServer()
return (
<Show when={server.url} keyed>
{props.children}
</Show>
)
}
export function App() { export function App() {
return ( return (
<MetaProvider> <MetaProvider>
<Font /> <Font />
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}> <ThemeProvider>
<DialogProvider> <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<MarkedProvider> <DialogProvider>
<DiffComponentProvider component={Diff}> <MarkedProvider>
<CodeComponentProvider component={Code}> <DiffComponentProvider component={Diff}>
<GlobalSDKProvider url={url}> <CodeComponentProvider component={Code}>
<GlobalSyncProvider> <ServerProvider defaultUrl={defaultServerUrl}>
<LayoutProvider> <ServerKey>
<NotificationProvider> <GlobalSDKProvider>
<Router <GlobalSyncProvider>
root={(props) => ( <LayoutProvider>
<CommandProvider> <NotificationProvider>
<Layout>{props.children}</Layout> <Router
</CommandProvider> root={(props) => (
)} <CommandProvider>
> <Layout>{props.children}</Layout>
<Route path="/" component={Home} /> </CommandProvider>
<Route path="/:dir" component={DirectoryLayout}> )}
<Route path="/" component={() => <Navigate href="session" />} /> >
<Route <Route path="/" component={Home} />
path="/session/:id?" <Route path="/:dir" component={DirectoryLayout}>
component={(p) => ( <Route path="/" component={() => <Navigate href="session" />} />
<Show when={p.params.id || true} keyed> <Route
<TerminalProvider> path="/session/:id?"
<PromptProvider> component={(p) => (
<Session /> <Show when={p.params.id ?? "new"} keyed>
</PromptProvider> <TerminalProvider>
</TerminalProvider> <PromptProvider>
</Show> <Session />
)} </PromptProvider>
/> </TerminalProvider>
</Route> </Show>
</Router> )}
</NotificationProvider> />
</LayoutProvider> </Route>
</GlobalSyncProvider> </Router>
</GlobalSDKProvider> </NotificationProvider>
</CodeComponentProvider> </LayoutProvider>
</DiffComponentProvider> </GlobalSyncProvider>
</MarkedProvider> </GlobalSDKProvider>
</DialogProvider> </ServerKey>
</ErrorBoundary> </ServerProvider>
</CodeComponentProvider>
</DiffComponentProvider>
</MarkedProvider>
</DialogProvider>
</ErrorBoundary>
</ThemeProvider>
</MetaProvider> </MetaProvider>
) )
} }
@@ -0,0 +1,180 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { TextField } from "@opencode-ai/ui/text-field"
import { Icon } from "@opencode-ai/ui/icon"
import { createMemo, createSignal, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobalSDK } from "@/context/global-sdk"
import { type LocalProject, getAvatarColors } from "@/context/layout"
import { Avatar } from "@opencode-ai/ui/avatar"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
function getFilename(input: string) {
const parts = input.split("/")
return parts[parts.length - 1] || input
}
export function DialogEditProject(props: { project: LocalProject }) {
const dialog = useDialog()
const globalSDK = useGlobalSDK()
const folderName = createMemo(() => getFilename(props.project.worktree))
const defaultName = createMemo(() => props.project.name || folderName())
const [store, setStore] = createStore({
name: defaultName(),
color: props.project.icon?.color || "pink",
iconUrl: props.project.icon?.url || "",
saving: false,
})
const [dragOver, setDragOver] = createSignal(false)
function handleFileSelect(file: File) {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
reader.onload = (e) => setStore("iconUrl", e.target?.result as string)
reader.readAsDataURL(file)
}
function handleDrop(e: DragEvent) {
e.preventDefault()
setDragOver(false)
const file = e.dataTransfer?.files[0]
if (file) handleFileSelect(file)
}
function handleDragOver(e: DragEvent) {
e.preventDefault()
setDragOver(true)
}
function handleDragLeave() {
setDragOver(false)
}
function handleInputChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (file) handleFileSelect(file)
}
function clearIcon() {
setStore("iconUrl", "")
}
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
if (!props.project.id) return
setStore("saving", true)
const name = store.name.trim() === folderName() ? "" : store.name.trim()
await globalSDK.client.project.update({
projectID: props.project.id,
name,
icon: { color: store.color, url: store.iconUrl },
})
setStore("saving", false)
dialog.close()
}
return (
<Dialog title="Edit project">
<form onSubmit={handleSubmit} class="flex flex-col gap-6 px-2.5 pb-3">
<div class="flex flex-col gap-4">
<TextField
autofocus
type="text"
label="Name"
placeholder={folderName()}
value={store.name}
onChange={(v) => setStore("name", v)}
/>
<div class="flex flex-col gap-2">
<label class="text-12-medium text-text-weak">Icon</label>
<div class="flex gap-3 items-start">
<div class="relative">
<div
class="size-16 rounded-lg overflow-hidden border border-dashed transition-colors cursor-pointer"
classList={{
"border-text-interactive-base bg-surface-info-base/20": dragOver(),
"border-border-base hover:border-border-strong": !dragOver(),
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => document.getElementById("icon-upload")?.click()}
>
<Show
when={store.iconUrl}
fallback={
<div class="size-full flex items-center justify-center">
<Avatar
fallback={store.name || defaultName()}
{...getAvatarColors(store.color)}
class="size-full"
/>
</div>
}
>
<img src={store.iconUrl} alt="Project icon" class="size-full object-cover" />
</Show>
</div>
<Show when={store.iconUrl}>
<button
type="button"
class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-base border border-border-base flex items-center justify-center hover:bg-surface-raised-base-hover"
onClick={clearIcon}
>
<Icon name="close" class="size-3 text-icon-base" />
</button>
</Show>
</div>
<input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
<div class="flex flex-col gap-1.5 text-12-regular text-text-weak">
<span>Click or drag an image</span>
<span>Recommended: 128x128px</span>
</div>
</div>
</div>
<Show when={!store.iconUrl}>
<div class="flex flex-col gap-2">
<label class="text-12-medium text-text-weak">Color</label>
<div class="flex gap-2">
<For each={AVATAR_COLOR_KEYS}>
{(color) => (
<button
type="button"
class="relative size-8 rounded-md transition-all"
classList={{
"ring-2 ring-offset-2 ring-offset-surface-base ring-text-interactive-base":
store.color === color,
}}
style={{ background: getAvatarColors(color).background }}
onClick={() => setStore("color", color)}
>
<Avatar fallback={store.name || defaultName()} {...getAvatarColors(color)} class="size-full" />
</button>
)}
</For>
</div>
</div>
</Show>
</div>
<div class="flex justify-end gap-2">
<Button type="button" variant="ghost" size="large" onClick={() => dialog.close()}>
Cancel
</Button>
<Button type="submit" variant="primary" size="large" disabled={store.saving}>
{store.saving ? "Saving..." : "Save"}
</Button>
</div>
</form>
</Dialog>
)
}
@@ -0,0 +1,114 @@
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 { createMemo } from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
interface DialogSelectDirectoryProps {
title?: string
multiple?: boolean
onSelect: (result: string | string[] | null) => void
}
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const sync = useGlobalSync()
const sdk = useGlobalSDK()
const dialog = useDialog()
const home = createMemo(() => sync.data.path.home)
const root = createMemo(() => sync.data.path.home || sync.data.path.directory)
function join(base: string | undefined, rel: string) {
const b = (base ?? "").replace(/[\\/]+$/, "")
const r = rel.replace(/^[\\/]+/, "").replace(/[\\/]+$/, "")
if (!b) return r
if (!r) return b
return b + "/" + r
}
function display(rel: string) {
const full = join(root(), rel)
const h = home()
if (!h) return full
if (full === h) return "~"
if (full.startsWith(h + "/") || full.startsWith(h + "\\")) {
return "~" + full.slice(h.length)
}
return full
}
function normalizeQuery(query: string) {
const h = home()
if (!query) return query
if (query.startsWith("~/")) return query.slice(2)
if (h) {
const lc = query.toLowerCase()
const hc = h.toLowerCase()
if (lc === hc || lc.startsWith(hc + "/") || lc.startsWith(hc + "\\")) {
return query.slice(h.length).replace(/^[\\/]+/, "")
}
}
return query
}
async function fetchDirs(query: string) {
const directory = root()
if (!directory) return [] as string[]
const results = await sdk.client.find
.files({ directory, query, type: "directory", limit: 50 })
.then((x) => x.data ?? [])
.catch(() => [])
return results.map((x) => x.replace(/[\\/]+$/, ""))
}
const directories = async (filter: string) => {
const query = normalizeQuery(filter.trim())
return fetchDirs(query)
}
function resolve(rel: string) {
const absolute = join(root(), rel)
props.onSelect(props.multiple ? [absolute] : absolute)
dialog.close()
}
return (
<Dialog title={props.title ?? "Open project"}>
<List
search={{ placeholder: "Search folders", autofocus: true }}
emptyMessage="No folders found"
items={directories}
key={(x) => x}
onSelect={(path) => {
if (!path) return
resolve(path)
}}
>
{(rel) => {
const path = display(rel)
return (
<div class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: rel, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(path)}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
</div>
</div>
</div>
)
}}
</List>
</Dialog>
)
}
@@ -0,0 +1,91 @@
import { Component, createMemo, createSignal, Show } from "solid-js"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch"
export const DialogSelectMcp: Component = () => {
const sync = useSync()
const sdk = useSDK()
const [loading, setLoading] = createSignal<string | null>(null)
const items = createMemo(() =>
Object.entries(sync.data.mcp ?? {})
.map(([name, status]) => ({ name, status: status.status }))
.sort((a, b) => a.name.localeCompare(b.name)),
)
const toggle = async (name: string) => {
if (loading()) return
setLoading(name)
const status = sync.data.mcp[name]
if (status?.status === "connected") {
await sdk.client.mcp.disconnect({ name })
} else {
await sdk.client.mcp.connect({ name })
}
const result = await sdk.client.mcp.status()
if (result.data) sync.set("mcp", result.data)
setLoading(null)
}
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
const totalCount = createMemo(() => items().length)
return (
<Dialog title="MCPs" description={`${enabledCount()} of ${totalCount()} enabled`}>
<List
search={{ placeholder: "Search", autofocus: true }}
emptyMessage="No MCPs configured"
key={(x) => x?.name ?? ""}
items={items}
filterKeys={["name", "status"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
onSelect={(x) => {
if (x) toggle(x.name)
}}
>
{(i) => {
const mcpStatus = () => sync.data.mcp[i.name]
const status = () => mcpStatus()?.status
const error = () => {
const s = mcpStatus()
return s?.status === "failed" ? s.error : undefined
}
const enabled = () => status() === "connected"
return (
<div class="w-full flex items-center justify-between gap-x-3">
<div class="flex flex-col gap-0.5 min-w-0">
<div class="flex items-center gap-2">
<span class="truncate">{i.name}</span>
<Show when={status() === "connected"}>
<span class="text-11-regular text-text-weaker">connected</span>
</Show>
<Show when={status() === "failed"}>
<span class="text-11-regular text-text-weaker">failed</span>
</Show>
<Show when={status() === "needs_auth"}>
<span class="text-11-regular text-text-weaker">needs auth</span>
</Show>
<Show when={status() === "disabled"}>
<span class="text-11-regular text-text-weaker">disabled</span>
</Show>
<Show when={loading() === i.name}>
<span class="text-11-regular text-text-weak">...</span>
</Show>
</div>
<Show when={error()}>
<span class="text-11-regular text-text-weaker truncate">{error()}</span>
</Show>
</div>
<div onClick={(e) => e.stopPropagation()}>
<Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} />
</div>
</div>
)
}}
</List>
</Dialog>
)
}
@@ -0,0 +1,179 @@
import { createEffect, createMemo, onCleanup } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field"
import { Button } from "@opencode-ai/ui/button"
import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { useNavigate } from "@solidjs/router"
type ServerStatus = { healthy: boolean; version?: string }
async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promise<ServerStatus> {
const sdk = createOpencodeClient({
baseUrl: url,
fetch,
signal: AbortSignal.timeout(3000),
})
return sdk.global
.health()
.then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
.catch(() => ({ healthy: false }))
}
export function DialogSelectServer() {
const navigate = useNavigate()
const dialog = useDialog()
const server = useServer()
const platform = usePlatform()
const [store, setStore] = createStore({
url: "",
adding: false,
error: "",
status: {} as Record<string, ServerStatus | undefined>,
})
const items = createMemo(() => {
const current = server.url
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((x) => x !== current)]
})
const current = createMemo(() => items().find((x) => x === server.url) ?? items()[0])
const sortedItems = createMemo(() => {
const list = items()
if (!list.length) return list
const active = current()
const order = new Map(list.map((url, index) => [url, index] as const))
const rank = (value?: ServerStatus) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff = rank(store.status[a]) - rank(store.status[b])
if (diff !== 0) return diff
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
})
async function refreshHealth() {
const results: Record<string, ServerStatus> = {}
await Promise.all(
items().map(async (url) => {
results[url] = await checkHealth(url, platform.fetch)
}),
)
setStore("status", reconcile(results))
}
createEffect(() => {
items()
refreshHealth()
const interval = setInterval(refreshHealth, 10_000)
onCleanup(() => clearInterval(interval))
})
function select(value: string, persist?: boolean) {
if (!persist && store.status[value]?.healthy === false) return
dialog.close()
if (persist) {
server.add(value)
navigate("/")
return
}
server.setActive(value)
navigate("/")
}
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const value = normalizeServerUrl(store.url)
if (!value) return
setStore("adding", true)
setStore("error", "")
const result = await checkHealth(value, platform.fetch)
setStore("adding", false)
if (!result.healthy) {
setStore("error", "Could not connect to server")
return
}
setStore("url", "")
select(value, true)
}
return (
<Dialog title="Servers" description="Switch which OpenCode server this app connects to.">
<div class="flex flex-col gap-4 pb-4">
<List
search={{ placeholder: "Search servers", autofocus: true }}
emptyMessage="No servers yet"
items={sortedItems}
key={(x) => x}
current={current()}
onSelect={(x) => {
if (x) select(x)
}}
>
{(i) => (
<div
class="flex items-center gap-2 min-w-0 flex-1"
classList={{ "opacity-50": store.status[i]?.healthy === false }}
>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": store.status[i]?.healthy === true,
"bg-icon-critical-base": store.status[i]?.healthy === false,
"bg-border-weak-base": store.status[i] === undefined,
}}
/>
<span class="truncate">{serverDisplayName(i)}</span>
<span class="text-text-weak">{store.status[i]?.version}</span>
</div>
)}
</List>
<div class="mt-6 px-3 flex flex-col gap-1.5">
<div class="px-3">
<h3 class="text-14-regular text-text-weak">Add a server</h3>
</div>
<form onSubmit={handleSubmit}>
<div class="flex items-start gap-2">
<div class="flex-1 min-w-0 h-auto">
<TextField
type="text"
label="Server URL"
hideLabel
placeholder="http://localhost:4096"
value={store.url}
onChange={(v) => {
setStore("url", v)
setStore("error", "")
}}
validationState={store.error ? "invalid" : "valid"}
error={store.error}
/>
</div>
<Button type="submit" variant="secondary" icon="plus-small" size="large" disabled={store.adding}>
{store.adding ? "Checking..." : "Add"}
</Button>
</div>
</form>
</div>
</div>
</Dialog>
)
}
+4
View File
@@ -188,6 +188,10 @@ export function Header(props: {
shareURL = await globalSDK.client.session shareURL = await globalSDK.client.session
.share({ sessionID: session.id, directory: currentDirectory() }) .share({ sessionID: session.id, directory: currentDirectory() })
.then((r) => r.data?.share?.url) .then((r) => r.data?.share?.url)
.catch((e) => {
console.error("Failed to share session", e)
return undefined
})
} }
return shareURL return shareURL
}, },
+438 -52
View File
@@ -82,6 +82,37 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const command = useCommand() const command = useCommand()
let editorRef!: HTMLDivElement let editorRef!: HTMLDivElement
let fileInputRef!: HTMLInputElement let fileInputRef!: HTMLInputElement
let scrollRef!: HTMLDivElement
const scrollCursorIntoView = () => {
const container = scrollRef
const selection = window.getSelection()
if (!container || !selection || selection.rangeCount === 0) return
const range = selection.getRangeAt(0)
if (!editorRef.contains(range.startContainer)) return
const rect = range.getBoundingClientRect()
if (!rect.height) return
const containerRect = container.getBoundingClientRect()
const top = rect.top - containerRect.top + container.scrollTop
const bottom = rect.bottom - containerRect.top + container.scrollTop
const padding = 12
if (top < container.scrollTop + padding) {
container.scrollTop = Math.max(0, top - padding)
return
}
if (bottom > container.scrollTop + container.clientHeight - padding) {
container.scrollTop = bottom - container.clientHeight + padding
}
}
const queueScroll = () => {
requestAnimationFrame(scrollCursorIntoView)
}
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey())) const tabs = createMemo(() => layout.tabs(sessionKey()))
@@ -103,6 +134,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
imageAttachments: ImageAttachmentPart[] imageAttachments: ImageAttachmentPart[]
mode: "normal" | "shell" mode: "normal" | "shell"
applyingHistory: boolean applyingHistory: boolean
killBuffer: string
}>({ }>({
popover: null, popover: null,
historyIndex: -1, historyIndex: -1,
@@ -112,6 +144,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
imageAttachments: [], imageAttachments: [],
mode: "normal", mode: "normal",
applyingHistory: false, applyingHistory: false,
killBuffer: "",
}) })
const MAX_HISTORY = 100 const MAX_HISTORY = 100
@@ -153,6 +186,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
editorRef.focus() editorRef.focus()
setCursorPosition(editorRef, length) setCursorPosition(editorRef, length)
setStore("applyingHistory", false) setStore("applyingHistory", false)
queueScroll()
}) })
} }
@@ -216,6 +250,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
const handlePaste = async (event: ClipboardEvent) => { const handlePaste = async (event: ClipboardEvent) => {
if (!isFocused()) return
const clipboardData = event.clipboardData const clipboardData = event.clipboardData
if (!clipboardData) return if (!clipboardData) return
@@ -238,7 +273,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
addPart({ type: "text", content: plainText, start: 0, end: 0 }) addPart({ type: "text", content: plainText, start: 0, end: 0 })
} }
const handleDragOver = (event: DragEvent) => { const handleGlobalDragOver = (event: DragEvent) => {
event.preventDefault() event.preventDefault()
const hasFiles = event.dataTransfer?.types.includes("Files") const hasFiles = event.dataTransfer?.types.includes("Files")
if (hasFiles) { if (hasFiles) {
@@ -246,15 +281,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
} }
const handleDragLeave = (event: DragEvent) => { const handleGlobalDragLeave = (event: DragEvent) => {
const related = event.relatedTarget as Node | null // relatedTarget is null when leaving the document window
const form = event.currentTarget as HTMLElement if (!event.relatedTarget) {
if (!related || !form.contains(related)) {
setStore("dragging", false) setStore("dragging", false)
} }
} }
const handleDrop = async (event: DragEvent) => { const handleGlobalDrop = async (event: DragEvent) => {
event.preventDefault() event.preventDefault()
setStore("dragging", false) setStore("dragging", false)
@@ -270,9 +304,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onMount(() => { onMount(() => {
editorRef.addEventListener("paste", handlePaste) editorRef.addEventListener("paste", handlePaste)
document.addEventListener("dragover", handleGlobalDragOver)
document.addEventListener("dragleave", handleGlobalDragLeave)
document.addEventListener("drop", handleGlobalDrop)
}) })
onCleanup(() => { onCleanup(() => {
editorRef.removeEventListener("paste", handlePaste) editorRef.removeEventListener("paste", handlePaste)
document.removeEventListener("dragover", handleGlobalDragOver)
document.removeEventListener("dragleave", handleGlobalDragLeave)
document.removeEventListener("drop", handleGlobalDrop)
}) })
createEffect(() => { createEffect(() => {
@@ -357,9 +397,23 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
(currentParts) => { (currentParts) => {
const domParts = parseFromDOM() const domParts = parseFromDOM()
const normalized = Array.from(editorRef.childNodes).every((node) => { const normalized = Array.from(editorRef.childNodes).every((node) => {
if (node.nodeType === Node.TEXT_NODE) return true if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent ?? ""
if (!text.includes("\u200B")) return true
if (text !== "\u200B") return false
const prev = node.previousSibling
const next = node.nextSibling
const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR"
const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR"
if (!prevIsBr && !nextIsBr) return false
if (nextIsBr && !prevIsBr && prev) return false
return true
}
if (node.nodeType !== Node.ELEMENT_NODE) return false if (node.nodeType !== Node.ELEMENT_NODE) return false
return (node as HTMLElement).dataset.type === "file" const el = node as HTMLElement
if (el.dataset.type === "file") return true
return el.tagName === "BR"
}) })
if (normalized && isPromptEqual(currentParts, domParts)) return if (normalized && isPromptEqual(currentParts, domParts)) return
@@ -372,7 +426,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
editorRef.innerHTML = "" editorRef.innerHTML = ""
currentParts.forEach((part) => { currentParts.forEach((part) => {
if (part.type === "text") { if (part.type === "text") {
editorRef.appendChild(document.createTextNode(part.content)) editorRef.appendChild(createTextFragment(part.content))
} else if (part.type === "file") { } else if (part.type === "file") {
const pill = document.createElement("span") const pill = document.createElement("span")
pill.textContent = part.content pill.textContent = part.content
@@ -398,7 +452,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let buffer = "" let buffer = ""
const flushText = () => { const flushText = () => {
const content = buffer.replace(/\r\n?/g, "\n") const content = buffer.replace(/\r\n?/g, "\n").replace(/\u200B/g, "")
buffer = "" buffer = ""
if (!content) return if (!content) return
parts.push({ type: "text", content, start: position, end: position + content.length }) parts.push({ type: "text", content, start: position, end: position + content.length })
@@ -472,6 +526,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (prompt.dirty()) { if (prompt.dirty()) {
prompt.set(DEFAULT_PROMPT, 0) prompt.set(DEFAULT_PROMPT, 0)
} }
queueScroll()
return return
} }
@@ -500,6 +555,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
prompt.set(rawParts, cursorPosition) prompt.set(rawParts, cursorPosition)
queueScroll()
} }
const addPart = (part: ContentPart) => { const addPart = (part: ContentPart) => {
@@ -529,9 +585,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const nodes = Array.from(editorRef.childNodes) const nodes = Array.from(editorRef.childNodes)
for (const node of nodes) { for (const node of nodes) {
const length = node.textContent?.length ?? 0 const length = getNodeLength(node)
const isText = node.nodeType === Node.TEXT_NODE const isText = node.nodeType === Node.TEXT_NODE
const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file" const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
if (isText && remaining <= length) { if (isText && remaining <= length) {
if (edge === "start") range.setStart(node, remaining) if (edge === "start") range.setStart(node, remaining)
@@ -539,7 +596,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return return
} }
if (isFile && remaining <= length) { if ((isFile || isBreak) && remaining <= length) {
if (edge === "start" && remaining === 0) range.setStartBefore(node) if (edge === "start" && remaining === 0) range.setStartBefore(node)
if (edge === "start" && remaining > 0) range.setStartAfter(node) if (edge === "start" && remaining > 0) range.setStartAfter(node)
if (edge === "end" && remaining === 0) range.setEndBefore(node) if (edge === "end" && remaining === 0) range.setEndBefore(node)
@@ -565,11 +622,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
selection.removeAllRanges() selection.removeAllRanges()
selection.addRange(range) selection.addRange(range)
} else if (part.type === "text") { } else if (part.type === "text") {
const textNode = document.createTextNode(part.content)
const range = selection.getRangeAt(0) const range = selection.getRangeAt(0)
const fragment = createTextFragment(part.content)
const last = fragment.lastChild
range.deleteContents() range.deleteContents()
range.insertNode(textNode) range.insertNode(fragment)
range.setStartAfter(textNode) if (last) {
if (last.nodeType === Node.TEXT_NODE) {
const text = last.textContent ?? ""
if (text === "\u200B") {
range.setStart(last, 0)
}
if (text !== "\u200B") {
range.setStart(last, text.length)
}
}
if (last.nodeType !== Node.TEXT_NODE) {
range.setStartAfter(last)
}
}
range.collapse(true) range.collapse(true)
selection.removeAllRanges() selection.removeAllRanges()
selection.addRange(range) selection.addRange(range)
@@ -579,10 +650,83 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setStore("popover", null) setStore("popover", null)
} }
const setSelectionOffsets = (start: number, end: number) => {
const selection = window.getSelection()
if (!selection) return false
const length = promptLength(prompt.current())
const a = Math.max(0, Math.min(start, length))
const b = Math.max(0, Math.min(end, length))
const rangeStart = Math.min(a, b)
const rangeEnd = Math.max(a, b)
const range = document.createRange()
range.selectNodeContents(editorRef)
const setEdge = (edge: "start" | "end", offset: number) => {
let remaining = offset
const nodes = Array.from(editorRef.childNodes)
for (const node of nodes) {
const length = getNodeLength(node)
const isText = node.nodeType === Node.TEXT_NODE
const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
if (isText && remaining <= length) {
if (edge === "start") range.setStart(node, remaining)
if (edge === "end") range.setEnd(node, remaining)
return
}
if ((isFile || isBreak) && remaining <= length) {
if (edge === "start" && remaining === 0) range.setStartBefore(node)
if (edge === "start" && remaining > 0) range.setStartAfter(node)
if (edge === "end" && remaining === 0) range.setEndBefore(node)
if (edge === "end" && remaining > 0) range.setEndAfter(node)
return
}
remaining -= length
}
const last = editorRef.lastChild
if (!last) {
if (edge === "start") range.setStart(editorRef, 0)
if (edge === "end") range.setEnd(editorRef, 0)
return
}
if (edge === "start") range.setStartAfter(last)
if (edge === "end") range.setEndAfter(last)
}
setEdge("start", rangeStart)
setEdge("end", rangeEnd)
selection.removeAllRanges()
selection.addRange(range)
return true
}
const replaceOffsets = (start: number, end: number, content: string) => {
if (!setSelectionOffsets(start, end)) return false
addPart({ type: "text", content, start: 0, end: 0 })
return true
}
const killText = (start: number, end: number) => {
if (start === end) return
const current = prompt.current()
if (!current.every((part) => part.type === "text")) return
const text = current.map((part) => part.content).join("")
setStore("killBuffer", text.slice(start, end))
}
const abort = () => const abort = () =>
sdk.client.session.abort({ sdk.client.session
sessionID: params.id!, .abort({
}) sessionID: params.id!,
})
.catch(() => {})
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
const text = prompt const text = prompt
@@ -646,6 +790,24 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Backspace") {
const selection = window.getSelection()
if (selection && selection.isCollapsed) {
const node = selection.anchorNode
const offset = selection.anchorOffset
if (node && node.nodeType === Node.TEXT_NODE) {
const text = node.textContent ?? ""
if (/^\u200B+$/.test(text) && offset > 0) {
const range = document.createRange()
range.setStart(node, 0)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
}
}
}
}
if (event.key === "!" && store.mode === "normal") { if (event.key === "!" && store.mode === "normal") {
const cursorPosition = getCursorPosition(editorRef) const cursorPosition = getCursorPosition(editorRef)
if (cursorPosition === 0) { if (cursorPosition === 0) {
@@ -679,6 +841,164 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return return
} }
const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey
const alt = event.altKey && !event.metaKey && !event.ctrlKey && !event.shiftKey
if (ctrl && event.code === "KeyG") {
if (store.popover) {
setStore("popover", null)
event.preventDefault()
return
}
if (working()) {
abort()
event.preventDefault()
}
return
}
if (ctrl || alt) {
const { collapsed, cursorPosition, textLength } = getCaretState()
if (collapsed) {
const current = prompt.current()
const text = current.map((part) => ("content" in part ? part.content : "")).join("")
if (ctrl) {
if (event.code === "KeyA") {
const pos = text.lastIndexOf("\n", cursorPosition - 1) + 1
setCursorPosition(editorRef, pos)
event.preventDefault()
queueScroll()
return
}
if (event.code === "KeyE") {
const next = text.indexOf("\n", cursorPosition)
const pos = next === -1 ? textLength : next
setCursorPosition(editorRef, pos)
event.preventDefault()
queueScroll()
return
}
if (event.code === "KeyB") {
const pos = Math.max(0, cursorPosition - 1)
setCursorPosition(editorRef, pos)
event.preventDefault()
queueScroll()
return
}
if (event.code === "KeyF") {
const pos = Math.min(textLength, cursorPosition + 1)
setCursorPosition(editorRef, pos)
event.preventDefault()
queueScroll()
return
}
if (event.code === "KeyD") {
if (store.mode === "shell" && cursorPosition === 0 && textLength === 0) {
setStore("mode", "normal")
event.preventDefault()
return
}
if (cursorPosition >= textLength) return
replaceOffsets(cursorPosition, cursorPosition + 1, "")
event.preventDefault()
return
}
if (event.code === "KeyK") {
const next = text.indexOf("\n", cursorPosition)
const lineEnd = next === -1 ? textLength : next
const end = lineEnd === cursorPosition && lineEnd < textLength ? lineEnd + 1 : lineEnd
if (end === cursorPosition) return
killText(cursorPosition, end)
replaceOffsets(cursorPosition, end, "")
event.preventDefault()
return
}
if (event.code === "KeyU") {
const start = text.lastIndexOf("\n", cursorPosition - 1) + 1
if (start === cursorPosition) return
killText(start, cursorPosition)
replaceOffsets(start, cursorPosition, "")
event.preventDefault()
return
}
if (event.code === "KeyW") {
let start = cursorPosition
while (start > 0 && /\s/.test(text[start - 1])) start -= 1
while (start > 0 && !/\s/.test(text[start - 1])) start -= 1
if (start === cursorPosition) return
killText(start, cursorPosition)
replaceOffsets(start, cursorPosition, "")
event.preventDefault()
return
}
if (event.code === "KeyY") {
if (!store.killBuffer) return
addPart({ type: "text", content: store.killBuffer, start: 0, end: 0 })
event.preventDefault()
return
}
if (event.code === "KeyT") {
if (!current.every((part) => part.type === "text")) return
if (textLength < 2) return
if (cursorPosition === 0) return
const atEnd = cursorPosition === textLength
const first = atEnd ? cursorPosition - 2 : cursorPosition - 1
const second = atEnd ? cursorPosition - 1 : cursorPosition
if (text[first] === "\n" || text[second] === "\n") return
replaceOffsets(first, second + 1, `${text[second]}${text[first]}`)
event.preventDefault()
return
}
}
if (alt) {
if (event.code === "KeyB") {
let pos = cursorPosition
while (pos > 0 && /\s/.test(text[pos - 1])) pos -= 1
while (pos > 0 && !/\s/.test(text[pos - 1])) pos -= 1
setCursorPosition(editorRef, pos)
event.preventDefault()
queueScroll()
return
}
if (event.code === "KeyF") {
let pos = cursorPosition
while (pos < textLength && /\s/.test(text[pos])) pos += 1
while (pos < textLength && !/\s/.test(text[pos])) pos += 1
setCursorPosition(editorRef, pos)
event.preventDefault()
queueScroll()
return
}
if (event.code === "KeyD") {
let end = cursorPosition
while (end < textLength && /\s/.test(text[end])) end += 1
while (end < textLength && !/\s/.test(text[end])) end += 1
if (end === cursorPosition) return
killText(cursorPosition, end)
replaceOffsets(cursorPosition, end, "")
event.preventDefault()
return
}
}
}
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") { if (event.key === "ArrowUp" || event.key === "ArrowDown") {
if (event.altKey || event.ctrlKey || event.metaKey) return if (event.altKey || event.ctrlKey || event.metaKey) return
const { collapsed } = getCaretState() const { collapsed } = getCaretState()
@@ -686,7 +1006,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const cursorPosition = getCursorPosition(editorRef) const cursorPosition = getCursorPosition(editorRef)
const textLength = promptLength(prompt.current()) const textLength = promptLength(prompt.current())
const textContent = editorRef.textContent ?? "" const textContent = prompt
.current()
.map((part) => ("content" in part ? part.content : ""))
.join("")
const isEmpty = textContent.trim() === "" || textLength <= 1 const isEmpty = textContent.trim() === "" || textLength <= 1
const hasNewlines = textContent.includes("\n") const hasNewlines = textContent.includes("\n")
const inHistory = store.historyIndex >= 0 const inHistory = store.historyIndex >= 0
@@ -792,19 +1115,29 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setStore("imageAttachments", []) setStore("imageAttachments", [])
setStore("mode", "normal") setStore("mode", "normal")
const model = { const currentModel = local.model.current()
modelID: local.model.current()!.id, const currentAgent = local.agent.current()
providerID: local.model.current()!.provider.id, if (!currentModel || !currentAgent) {
console.warn("No agent or model available for prompt submission")
return
} }
const agent = local.agent.current()!.name const model = {
modelID: currentModel.id,
providerID: currentModel.provider.id,
}
const agent = currentAgent.name
if (isShellMode) { if (isShellMode) {
sdk.client.session.shell({ sdk.client.session
sessionID: existing.id, .shell({
agent, sessionID: existing.id,
model, agent,
command: text, model,
}) command: text,
})
.catch((e) => {
console.error("Failed to send shell command", e)
})
return return
} }
@@ -813,13 +1146,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const commandName = cmdName.slice(1) const commandName = cmdName.slice(1)
const customCommand = sync.data.command.find((c) => c.name === commandName) const customCommand = sync.data.command.find((c) => c.name === commandName)
if (customCommand) { if (customCommand) {
sdk.client.session.command({ sdk.client.session
sessionID: existing.id, .command({
command: commandName, sessionID: existing.id,
arguments: args.join(" "), command: commandName,
agent, arguments: args.join(" "),
model: `${model.providerID}/${model.modelID}`, agent,
}) model: `${model.providerID}/${model.modelID}`,
})
.catch((e) => {
console.error("Failed to send command", e)
})
return return
} }
} }
@@ -845,13 +1182,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
model, model,
}) })
sdk.client.session.prompt({ sdk.client.session
sessionID: existing.id, .prompt({
agent, sessionID: existing.id,
model, agent,
messageID, model,
parts: requestParts, messageID,
}) parts: requestParts,
})
.catch((e) => {
console.error("Failed to send prompt", e)
})
} }
return ( return (
@@ -926,9 +1267,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Show> </Show>
<form <form
onSubmit={handleSubmit} onSubmit={handleSubmit}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
classList={{ classList={{
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true, "bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
"rounded-md overflow-clip focus-within:shadow-xs-border": true, "rounded-md overflow-clip focus-within:shadow-xs-border": true,
@@ -978,7 +1316,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</For> </For>
</div> </div>
</Show> </Show>
<div class="relative max-h-[240px] overflow-y-auto"> <div class="relative max-h-[240px] overflow-y-auto" ref={(el) => (scrollRef = el)}>
<div <div
data-component="prompt-input" data-component="prompt-input"
ref={(el) => { ref={(el) => {
@@ -989,6 +1327,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onInput={handleInput} onInput={handleInput}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
classList={{ classList={{
"select-text": true,
"w-full px-5 py-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true, "w-full px-5 py-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&_[data-type=file]]:text-icon-info-active": true, "[&_[data-type=file]]:text-icon-info-active": true,
"font-mono!": store.mode === "shell", "font-mono!": store.mode === "shell",
@@ -1027,7 +1366,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
> >
<Select <Select
options={local.agent.list().map((agent) => agent.name)} options={local.agent.list().map((agent) => agent.name)}
current={local.agent.current().name} current={local.agent.current()?.name ?? ""}
onSelect={local.agent.set} onSelect={local.agent.set}
class="capitalize" class="capitalize"
variant="ghost" variant="ghost"
@@ -1119,23 +1458,56 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
) )
} }
function createTextFragment(content: string): DocumentFragment {
const fragment = document.createDocumentFragment()
const segments = content.split("\n")
segments.forEach((segment, index) => {
if (segment) {
fragment.appendChild(document.createTextNode(segment))
} else if (segments.length > 1) {
fragment.appendChild(document.createTextNode("\u200B"))
}
if (index < segments.length - 1) {
fragment.appendChild(document.createElement("br"))
}
})
return fragment
}
function getNodeLength(node: Node): number {
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
return (node.textContent ?? "").replace(/\u200B/g, "").length
}
function getTextLength(node: Node): number {
if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length
if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
let length = 0
for (const child of Array.from(node.childNodes)) {
length += getTextLength(child)
}
return length
}
function getCursorPosition(parent: HTMLElement): number { function getCursorPosition(parent: HTMLElement): number {
const selection = window.getSelection() const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return 0 if (!selection || selection.rangeCount === 0) return 0
const range = selection.getRangeAt(0) const range = selection.getRangeAt(0)
if (!parent.contains(range.startContainer)) return 0
const preCaretRange = range.cloneRange() const preCaretRange = range.cloneRange()
preCaretRange.selectNodeContents(parent) preCaretRange.selectNodeContents(parent)
preCaretRange.setEnd(range.startContainer, range.startOffset) preCaretRange.setEnd(range.startContainer, range.startOffset)
return preCaretRange.toString().length return getTextLength(preCaretRange.cloneContents())
} }
function setCursorPosition(parent: HTMLElement, position: number) { function setCursorPosition(parent: HTMLElement, position: number) {
let remaining = position let remaining = position
let node = parent.firstChild let node = parent.firstChild
while (node) { while (node) {
const length = node.textContent ? node.textContent.length : 0 const length = getNodeLength(node)
const isText = node.nodeType === Node.TEXT_NODE const isText = node.nodeType === Node.TEXT_NODE
const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file" const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
if (isText && remaining <= length) { if (isText && remaining <= length) {
const range = document.createRange() const range = document.createRange()
@@ -1147,10 +1519,24 @@ function setCursorPosition(parent: HTMLElement, position: number) {
return return
} }
if (isFile && remaining <= length) { if ((isFile || isBreak) && remaining <= length) {
const range = document.createRange() const range = document.createRange()
const selection = window.getSelection() const selection = window.getSelection()
range.setStartAfter(node) if (remaining === 0) {
range.setStartBefore(node)
}
if (remaining > 0 && isFile) {
range.setStartAfter(node)
}
if (remaining > 0 && isBreak) {
const next = node.nextSibling
if (next && next.nodeType === Node.TEXT_NODE) {
range.setStart(next, 0)
}
if (!next || next.nodeType !== Node.TEXT_NODE) {
range.setStartAfter(node)
}
}
range.collapse(true) range.collapse(true)
selection?.removeAllRanges() selection?.removeAllRanges()
selection?.addRange(range) selection?.addRange(range)
@@ -3,7 +3,7 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { AssistantMessage } from "@opencode-ai/sdk/v2" import { AssistantMessage } from "@opencode-ai/sdk/v2/client"
export function SessionContextUsage() { export function SessionContextUsage() {
const sync = useSync() const sync = useSync()
@@ -35,19 +35,13 @@ export function SessionContextUsage() {
{(ctx) => ( {(ctx) => (
<Tooltip <Tooltip
value={ value={
<div class="flex flex-col gap-1"> <div class="grid grid-cols-2 gap-x-3 gap-y-1">
<div class="flex gap-3"> <span class="opacity-70 text-right">Tokens</span>
<span class="opacity-70 text-right flex-1">Tokens</span> <span class="text-left">{ctx().tokens}</span>
<span class="text-left flex-1">{ctx().tokens}</span> <span class="opacity-70 text-right">Usage</span>
</div> <span class="text-left">{ctx().percentage ?? 0}%</span>
<div class="flex gap-3"> <span class="opacity-70 text-right">Cost</span>
<span class="opacity-70 text-right flex-1">Usage</span> <span class="text-left">{cost()}</span>
<span class="text-left flex-1">{ctx().percentage ?? 0}%</span>
</div>
<div class="flex gap-3">
<span class="opacity-70 text-right flex-1">Cost</span>
<span class="text-left flex-1">{cost()}</span>
</div>
</div> </div>
} }
placement="top" placement="top"
@@ -0,0 +1,38 @@
import { createMemo, Show } from "solid-js"
import { useSync } from "@/context/sync"
import { Tooltip } from "@opencode-ai/ui/tooltip"
export function SessionLspIndicator() {
const sync = useSync()
const lspStats = createMemo(() => {
const lsp = sync.data.lsp ?? []
const connected = lsp.filter((s) => s.status === "connected").length
const hasError = lsp.some((s) => s.status === "error")
const total = lsp.length
return { connected, hasError, total }
})
const tooltipContent = createMemo(() => {
const lsp = sync.data.lsp ?? []
if (lsp.length === 0) return "No LSP servers"
return lsp.map((s) => s.name).join(", ")
})
return (
<Show when={lspStats().total > 0}>
<Tooltip placement="top" value={tooltipContent()}>
<div class="flex items-center gap-1 px-2 cursor-default select-none">
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-critical-base": lspStats().hasError,
"bg-icon-success-base": !lspStats().hasError && lspStats().connected > 0,
}}
/>
<span class="text-12-regular text-text-weak">{lspStats().connected} LSP</span>
</div>
</Tooltip>
</Show>
)
}
@@ -0,0 +1,34 @@
import { createMemo, Show } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useSync } from "@/context/sync"
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
export function SessionMcpIndicator() {
const sync = useSync()
const dialog = useDialog()
const mcpStats = createMemo(() => {
const mcp = sync.data.mcp ?? {}
const entries = Object.entries(mcp)
const enabled = entries.filter(([, status]) => status.status === "connected").length
const failed = entries.some(([, status]) => status.status === "failed")
const total = entries.length
return { enabled, failed, total }
})
return (
<Show when={mcpStats().total > 0}>
<Button variant="ghost" onClick={() => dialog.show(() => <DialogSelectMcp />)}>
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-critical-base": mcpStats().failed,
"bg-icon-success-base": !mcpStats().failed && mcpStats().enabled > 0,
}}
/>
<span class="text-12-regular text-text-weak">{mcpStats().enabled} MCP</span>
</Button>
</Show>
)
}
@@ -0,0 +1,53 @@
import { createMemo, Show, type ParentProps } from "solid-js"
import { useSync } from "@/context/sync"
import { useGlobalSync } from "@/context/global-sync"
import { useServer } from "@/context/server"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Button } from "@opencode-ai/ui/button"
import { DialogSelectServer } from "@/components/dialog-select-server"
export function StatusBar(props: ParentProps) {
const dialog = useDialog()
const server = useServer()
const sync = useSync()
const globalSync = useGlobalSync()
const directoryDisplay = createMemo(() => {
const directory = sync.data.path.directory || ""
const home = globalSync.data.path.home || ""
const short = home && directory.startsWith(home) ? directory.replace(home, "~") : directory
const branch = sync.data.vcs?.branch
return branch ? `${short}:${branch}` : short
})
return (
<div class="h-8 w-full shrink-0 flex items-center justify-between px-2 border-t border-border-weak-base bg-background-base">
<div class="flex items-center gap-3">
<div class="flex items-center gap-1">
<Button
size="small"
variant="ghost"
onClick={() => {
dialog.show(() => <DialogSelectServer />)
}}
>
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-success-base": server.healthy() === true,
"bg-icon-critical-base": server.healthy() === false,
"bg-border-weak-base": server.healthy() === undefined,
}}
/>
<span class="text-12-regular text-text-weak">{server.name}</span>
</Button>
</div>
<Show when={directoryDisplay()}>
<span class="text-12-regular text-text-weak">{directoryDisplay()}</span>
</Show>
</div>
<div class="flex items-center">{props.children}</div>
</div>
)
}
+114 -31
View File
@@ -1,9 +1,9 @@
import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web" import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js" import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { SerializeAddon } from "@/addons/serialize" import { SerializeAddon } from "@/addons/serialize"
import { LocalPTY } from "@/context/terminal" import { LocalPTY } from "@/context/terminal"
import { usePrefersDark } from "@solid-primitives/media" import { resolveThemeVariant, useTheme } from "@opencode-ai/ui/theme"
export interface TerminalProps extends ComponentProps<"div"> { export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY pty: LocalPTY
@@ -12,8 +12,28 @@ export interface TerminalProps extends ComponentProps<"div"> {
onConnectError?: (error: unknown) => void onConnectError?: (error: unknown) => void
} }
type TerminalColors = {
background: string
foreground: string
cursor: string
}
const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
light: {
background: "#fcfcfc",
foreground: "#211e1e",
cursor: "#211e1e",
},
dark: {
background: "#191515",
foreground: "#d4d4d4",
cursor: "#d4d4d4",
},
}
export const Terminal = (props: TerminalProps) => { export const Terminal = (props: TerminalProps) => {
const sdk = useSDK() const sdk = useSDK()
const theme = useTheme()
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"]) const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
let ws: WebSocket let ws: WebSocket
@@ -22,7 +42,64 @@ export const Terminal = (props: TerminalProps) => {
let serializeAddon: SerializeAddon let serializeAddon: SerializeAddon
let fitAddon: FitAddon let fitAddon: FitAddon
let handleResize: () => void let handleResize: () => void
const prefersDark = usePrefersDark()
const getTerminalColors = (): TerminalColors => {
const mode = theme.mode()
const fallback = DEFAULT_TERMINAL_COLORS[mode]
const currentTheme = theme.themes()[theme.themeId()]
if (!currentTheme) return fallback
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
if (!variant?.seeds) return fallback
const resolved = resolveThemeVariant(variant, mode === "dark")
const text = resolved["text-base"] ?? fallback.foreground
const background = resolved["background-stronger"] ?? fallback.background
return {
background,
foreground: text,
cursor: text,
}
}
const [terminalColors, setTerminalColors] = createSignal<TerminalColors>(getTerminalColors())
createEffect(() => {
const colors = getTerminalColors()
setTerminalColors(colors)
if (!term) return
const setOption = (term as unknown as { setOption?: (key: string, value: TerminalColors) => void }).setOption
if (!setOption) return
setOption("theme", colors)
})
const focusTerminal = () => term?.focus()
const copySelection = () => {
if (!term || !term.hasSelection()) return false
const selection = term.getSelection()
if (!selection) return false
const clipboard = navigator.clipboard
if (clipboard?.writeText) {
clipboard.writeText(selection).catch(() => {})
return true
}
if (!document.body) return false
const textarea = document.createElement("textarea")
textarea.value = selection
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
document.body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
document.body.removeChild(textarea)
return copied
}
const handlePointerDown = () => {
const activeElement = document.activeElement
if (activeElement instanceof HTMLElement && activeElement !== container) {
activeElement.blur()
}
focusTerminal()
}
onMount(async () => { onMount(async () => {
ghostty = await Ghostty.load() ghostty = await Ghostty.load()
@@ -33,23 +110,22 @@ export const Terminal = (props: TerminalProps) => {
fontSize: 14, fontSize: 14,
fontFamily: "IBM Plex Mono, monospace", fontFamily: "IBM Plex Mono, monospace",
allowTransparency: true, allowTransparency: true,
theme: prefersDark() theme: terminalColors(),
? {
background: "#191515",
foreground: "#d4d4d4",
cursor: "#d4d4d4",
}
: {
background: "#fcfcfc",
foreground: "#211e1e",
cursor: "#211e1e",
},
scrollback: 10_000, scrollback: 10_000,
ghostty, ghostty,
}) })
term.attachCustomKeyEventHandler((event) => { term.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase()
if (key === "c") {
const macCopy = event.metaKey && !event.ctrlKey && !event.altKey
const linuxCopy = event.ctrlKey && event.shiftKey && !event.metaKey
if ((macCopy || linuxCopy) && copySelection()) {
event.preventDefault()
return true
}
}
// allow for ctrl-` to toggle terminal in parent // allow for ctrl-` to toggle terminal in parent
if (event.ctrlKey && event.key.toLowerCase() === "`") { if (event.ctrlKey && key === "`") {
event.preventDefault() event.preventDefault()
return true return true
} }
@@ -62,6 +138,8 @@ export const Terminal = (props: TerminalProps) => {
term.loadAddon(fitAddon) term.loadAddon(fitAddon)
term.open(container) term.open(container)
container.addEventListener("pointerdown", handlePointerDown)
focusTerminal()
if (local.pty.buffer) { if (local.pty.buffer) {
if (local.pty.rows && local.pty.cols) { if (local.pty.rows && local.pty.cols) {
@@ -75,20 +153,20 @@ export const Terminal = (props: TerminalProps) => {
fitAddon.fit() fitAddon.fit()
} }
container.focus()
fitAddon.observeResize() fitAddon.observeResize()
handleResize = () => fitAddon.fit() handleResize = () => fitAddon.fit()
window.addEventListener("resize", handleResize) window.addEventListener("resize", handleResize)
term.onResize(async (size) => { term.onResize(async (size) => {
if (ws && ws.readyState === WebSocket.OPEN) { if (ws && ws.readyState === WebSocket.OPEN) {
await sdk.client.pty.update({ await sdk.client.pty
ptyID: local.pty.id, .update({
size: { ptyID: local.pty.id,
cols: size.cols, size: {
rows: size.rows, cols: size.cols,
}, rows: size.rows,
}) },
})
.catch(() => {})
} }
}) })
term.onData((data) => { term.onData((data) => {
@@ -106,13 +184,15 @@ export const Terminal = (props: TerminalProps) => {
// }) // })
ws.addEventListener("open", () => { ws.addEventListener("open", () => {
console.log("WebSocket connected") console.log("WebSocket connected")
sdk.client.pty.update({ sdk.client.pty
ptyID: local.pty.id, .update({
size: { ptyID: local.pty.id,
cols: term.cols, size: {
rows: term.rows, cols: term.cols,
}, rows: term.rows,
}) },
})
.catch(() => {})
}) })
ws.addEventListener("message", (event) => { ws.addEventListener("message", (event) => {
term.write(event.data) term.write(event.data)
@@ -130,6 +210,7 @@ export const Terminal = (props: TerminalProps) => {
if (handleResize) { if (handleResize) {
window.removeEventListener("resize", handleResize) window.removeEventListener("resize", handleResize)
} }
container.removeEventListener("pointerdown", handlePointerDown)
if (serializeAddon && props.onCleanup) { if (serializeAddon && props.onCleanup) {
const buffer = serializeAddon.serialize() const buffer = serializeAddon.serialize()
props.onCleanup({ props.onCleanup({
@@ -149,8 +230,10 @@ export const Terminal = (props: TerminalProps) => {
ref={container} ref={container}
data-component="terminal" data-component="terminal"
data-prevent-autofocus data-prevent-autofocus
style={{ "background-color": terminalColors().background }}
classList={{ classList={{
...(local.classList ?? {}), ...(local.classList ?? {}),
"select-text": true,
"size-full px-6 py-3 font-mono": true, "size-full px-6 py-3 font-mono": true,
[local.class ?? ""]: !!local.class, [local.class ?? ""]: !!local.class,
}} }}
+25 -6
View File
@@ -26,6 +26,7 @@ export interface CommandOption {
suggested?: boolean suggested?: boolean
disabled?: boolean disabled?: boolean
onSelect?: (source?: "palette" | "keybind" | "slash") => void onSelect?: (source?: "palette" | "keybind" | "slash") => void
onHighlight?: () => (() => void) | void
} }
export function parseKeybind(config: string): Keybind[] { export function parseKeybind(config: string): Keybind[] {
@@ -115,6 +116,28 @@ export function formatKeybind(config: string): string {
function DialogCommand(props: { options: CommandOption[] }) { function DialogCommand(props: { options: CommandOption[] }) {
const dialog = useDialog() const dialog = useDialog()
let cleanup: (() => void) | void
let committed = false
const handleMove = (option: CommandOption | undefined) => {
cleanup?.()
cleanup = option?.onHighlight?.()
}
const handleSelect = (option: CommandOption | undefined) => {
if (option) {
committed = true
cleanup = undefined
dialog.close()
option.onSelect?.("palette")
}
}
onCleanup(() => {
if (!committed) {
cleanup?.()
}
})
return ( return (
<Dialog title="Commands"> <Dialog title="Commands">
@@ -125,12 +148,8 @@ function DialogCommand(props: { options: CommandOption[] }) {
key={(x) => x?.id} key={(x) => x?.id}
filterKeys={["title", "description", "category"]} filterKeys={["title", "description", "category"]}
groupBy={(x) => x.category ?? ""} groupBy={(x) => x.category ?? ""}
onSelect={(option) => { onMove={handleMove}
if (option) { onSelect={handleSelect}
dialog.close()
option.onSelect?.("palette")
}
}}
> >
{(option) => ( {(option) => (
<div class="w-full flex items-center justify-between gap-4"> <div class="w-full flex items-center justify-between gap-4">
+15 -8
View File
@@ -1,34 +1,41 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client" import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
import { usePlatform } from "./platform" import { usePlatform } from "./platform"
import { useServer } from "./server"
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
name: "GlobalSDK", name: "GlobalSDK",
init: (props: { url: string }) => { init: () => {
const server = useServer()
const abort = new AbortController()
const eventSdk = createOpencodeClient({ const eventSdk = createOpencodeClient({
baseUrl: props.url, baseUrl: server.url,
// signal: AbortSignal.timeout(1000 * 60 * 10), signal: abort.signal,
}) })
const emitter = createGlobalEmitter<{ const emitter = createGlobalEmitter<{
[key: string]: Event [key: string]: Event
}>() }>()
eventSdk.global.event().then(async (events) => { void (async () => {
const events = await eventSdk.global.event()
for await (const event of events.stream) { for await (const event of events.stream) {
// console.log("event", event)
emitter.emit(event.directory ?? "global", event.payload) emitter.emit(event.directory ?? "global", event.payload)
} }
}) })().catch(() => undefined)
onCleanup(() => abort.abort())
const platform = usePlatform() const platform = usePlatform()
const sdk = createOpencodeClient({ const sdk = createOpencodeClient({
baseUrl: props.url, baseUrl: server.url,
signal: AbortSignal.timeout(1000 * 60 * 10), signal: AbortSignal.timeout(1000 * 60 * 10),
fetch: platform.fetch, fetch: platform.fetch,
throwOnError: true, throwOnError: true,
}) })
return { url: props.url, client: sdk, event: emitter } return { url: server.url, client: sdk, event: emitter }
}, },
}) })
+103 -8
View File
@@ -12,6 +12,10 @@ import {
type ProviderListResponse, type ProviderListResponse,
type ProviderAuthResponse, type ProviderAuthResponse,
type Command, type Command,
type McpStatus,
type LspStatus,
type VcsInfo,
type Permission,
createOpencodeClient, createOpencodeClient,
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
@@ -19,7 +23,7 @@ import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry" import { retry } from "@opencode-ai/util/retry"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { ErrorPage, type InitError } from "../pages/error" import { ErrorPage, type InitError } from "../pages/error"
import { createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js" import { batch, createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
@@ -41,6 +45,14 @@ type State = {
todo: { todo: {
[sessionID: string]: Todo[] [sessionID: string]: Todo[]
} }
permission: {
[sessionID: string]: Permission[]
}
mcp: {
[name: string]: McpStatus
}
lsp: LspStatus[]
vcs: VcsInfo | undefined
limit: number limit: number
message: { message: {
[sessionID: string]: Message[] [sessionID: string]: Message[]
@@ -59,21 +71,19 @@ function createGlobalSync() {
project: Project[] project: Project[]
provider: ProviderListResponse provider: ProviderListResponse
provider_auth: ProviderAuthResponse provider_auth: ProviderAuthResponse
children: Record<string, State>
}>({ }>({
ready: false, ready: false,
path: { state: "", config: "", worktree: "", directory: "", home: "" }, path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: [], project: [],
provider: { all: [], connected: [], default: {} }, provider: { all: [], connected: [], default: {} },
provider_auth: {}, provider_auth: {},
children: {},
}) })
const children: Record<string, ReturnType<typeof createStore<State>>> = {} const children: Record<string, ReturnType<typeof createStore<State>>> = {}
function child(directory: string) { function child(directory: string) {
if (!directory) console.error("No directory provided") if (!directory) console.error("No directory provided")
if (!children[directory]) { if (!children[directory]) {
setGlobalStore("children", directory, { children[directory] = createStore<State>({
project: "", project: "",
provider: { all: [], connected: [], default: {} }, provider: { all: [], connected: [], default: {} },
config: {}, config: {},
@@ -85,11 +95,14 @@ function createGlobalSync() {
session_status: {}, session_status: {},
session_diff: {}, session_diff: {},
todo: {}, todo: {},
permission: {},
mcp: {},
lsp: [],
vcs: undefined,
limit: 5, limit: 5,
message: {}, message: {},
part: {}, part: {},
}) })
children[directory] = createStore(globalStore.children[directory])
bootstrapInstance(directory) bootstrapInstance(directory)
} }
return children[directory] return children[directory]
@@ -111,7 +124,7 @@ function createGlobalSync() {
const updated = new Date(s.time.updated).getTime() const updated = new Date(s.time.updated).getTime()
return updated > fourHoursAgo return updated > fourHoursAgo
}) })
setStore("session", sessions) setStore("session", reconcile(sessions, { key: "id" }))
}) })
.catch((err) => { .catch((err) => {
console.error("Failed to load sessions", err) console.error("Failed to load sessions", err)
@@ -122,7 +135,7 @@ function createGlobalSync() {
async function bootstrapInstance(directory: string) { async function bootstrapInstance(directory: string) {
if (!directory) return if (!directory) return
const [, setStore] = child(directory) const [store, setStore] = child(directory)
const sdk = createOpencodeClient({ const sdk = createOpencodeClient({
baseUrl: globalSDK.url, baseUrl: globalSDK.url,
directory, directory,
@@ -149,6 +162,38 @@ function createGlobalSync() {
session: () => loadSessions(directory), session: () => loadSessions(directory),
status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)), status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
config: () => sdk.config.get().then((x) => setStore("config", x.data!)), config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
mcp: () => sdk.mcp.status().then((x) => setStore("mcp", x.data ?? {})),
lsp: () => sdk.lsp.status().then((x) => setStore("lsp", x.data ?? [])),
vcs: () => sdk.vcs.get().then((x) => setStore("vcs", x.data)),
permission: () =>
sdk.permission.list().then((x) => {
const grouped: Record<string, Permission[]> = {}
for (const perm of x.data ?? []) {
const existing = grouped[perm.sessionID]
if (existing) {
existing.push(perm)
continue
}
grouped[perm.sessionID] = [perm]
}
batch(() => {
for (const sessionID of Object.keys(store.permission)) {
if (grouped[sessionID]) continue
setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
setStore(
"permission",
sessionID,
reconcile(
permissions.slice().sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
})
}),
} }
await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e)))) await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
.then(() => setStore("ready", true)) .then(() => setStore("ready", true))
@@ -218,7 +263,7 @@ function createGlobalSync() {
setStore("session_diff", event.properties.sessionID, reconcile(event.properties.diff, { key: "file" })) setStore("session_diff", event.properties.sessionID, reconcile(event.properties.diff, { key: "file" }))
break break
case "todo.updated": case "todo.updated":
setStore("todo", event.properties.sessionID, reconcile(event.properties.todos)) setStore("todo", event.properties.sessionID, reconcile(event.properties.todos, { key: "id" }))
break break
case "session.status": { case "session.status": {
setStore("session_status", event.properties.sessionID, reconcile(event.properties.status)) setStore("session_status", event.properties.sessionID, reconcile(event.properties.status))
@@ -295,6 +340,56 @@ function createGlobalSync() {
} }
break break
} }
case "vcs.branch.updated": {
setStore("vcs", { branch: event.properties.branch })
break
}
case "permission.updated": {
const sessionID = event.properties.sessionID
const permissions = store.permission[sessionID]
if (!permissions) {
setStore("permission", sessionID, [event.properties])
break
}
const result = Binary.search(permissions, event.properties.id, (p) => p.id)
if (result.found) {
setStore("permission", sessionID, result.index, reconcile(event.properties))
break
}
setStore(
"permission",
sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties)
}),
)
break
}
case "permission.replied": {
const permissions = store.permission[event.properties.sessionID]
if (!permissions) break
const result = Binary.search(permissions, event.properties.permissionID, (p) => p.id)
if (!result.found) break
setStore(
"permission",
event.properties.sessionID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
break
}
case "lsp.updated": {
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
directory,
throwOnError: true,
})
sdk.lsp.status().then((x) => setStore("lsp", x.data ?? []))
break
}
} }
}) })
+12 -19
View File
@@ -3,6 +3,7 @@ import { batch, createMemo, onMount } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync" import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { useServer } from "./server"
import { Project } from "@opencode-ai/sdk/v2" import { Project } from "@opencode-ai/sdk/v2"
import { persisted } from "@/utils/persist" import { persisted } from "@/utils/persist"
@@ -34,10 +35,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
init: () => { init: () => {
const globalSdk = useGlobalSDK() const globalSdk = useGlobalSDK()
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const server = useServer()
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
"layout.v3", "layout.v4",
createStore({ createStore({
projects: [] as { worktree: string; expanded: boolean }[],
sidebar: { sidebar: {
opened: false, opened: false,
width: 280, width: 280,
@@ -70,6 +71,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
{ {
...project, ...project,
...(metadata ?? {}), ...(metadata ?? {}),
icon: { url: metadata?.icon?.url, color: metadata?.icon?.color },
}, },
] ]
} }
@@ -85,12 +87,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return project return project
} }
const enriched = createMemo(() => store.projects.flatMap(enrich)) const enriched = createMemo(() => server.projects.list().flatMap(enrich))
const list = createMemo(() => enriched().flatMap(colorize)) const list = createMemo(() => enriched().flatMap(colorize))
onMount(() => { onMount(() => {
Promise.all( Promise.all(
store.projects.map((project) => { server.projects.list().map((project) => {
return globalSync.project.loadSessions(project.worktree) return globalSync.project.loadSessions(project.worktree)
}), }),
) )
@@ -101,32 +103,23 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
projects: { projects: {
list, list,
open(directory: string) { open(directory: string) {
if (store.projects.find((x) => x.worktree === directory)) { if (server.projects.list().find((x) => x.worktree === directory)) {
return return
} }
globalSync.project.loadSessions(directory) globalSync.project.loadSessions(directory)
setStore("projects", (x) => [{ worktree: directory, expanded: true }, ...x]) server.projects.open(directory)
}, },
close(directory: string) { close(directory: string) {
setStore("projects", (x) => x.filter((x) => x.worktree !== directory)) server.projects.close(directory)
}, },
expand(directory: string) { expand(directory: string) {
const index = store.projects.findIndex((x) => x.worktree === directory) server.projects.expand(directory)
if (index !== -1) setStore("projects", index, "expanded", true)
}, },
collapse(directory: string) { collapse(directory: string) {
const index = store.projects.findIndex((x) => x.worktree === directory) server.projects.collapse(directory)
if (index !== -1) setStore("projects", index, "expanded", false)
}, },
move(directory: string, toIndex: number) { move(directory: string, toIndex: number) {
setStore("projects", (projects) => { server.projects.move(directory, toIndex)
const fromIndex = projects.findIndex((x) => x.worktree === directory)
if (fromIndex === -1 || fromIndex === toIndex) return projects
const result = [...projects]
const [item] = result.splice(fromIndex, 1)
result.splice(toIndex, 0, item)
return result
})
}, },
}, },
sidebar: { sidebar: {
+44 -21
View File
@@ -65,23 +65,40 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = (() => { const agent = (() => {
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
current: string current?: string
}>({ }>({
current: list()[0].name, current: list()[0]?.name,
}) })
return { return {
list, list,
current() { current() {
return list().find((x) => x.name === store.current)! const available = list()
if (available.length === 0) return undefined
return available.find((x) => x.name === store.current) ?? available[0]
}, },
set(name: string | undefined) { set(name: string | undefined) {
setStore("current", name ?? list()[0].name) const available = list()
if (available.length === 0) {
setStore("current", undefined)
return
}
if (name && available.some((x) => x.name === name)) {
setStore("current", name)
return
}
setStore("current", available[0].name)
}, },
move(direction: 1 | -1) { move(direction: 1 | -1) {
let next = list().findIndex((x) => x.name === store.current) + direction const available = list()
if (next < 0) next = list().length - 1 if (available.length === 0) {
if (next >= list().length) next = 0 setStore("current", undefined)
const value = list()[next] return
}
let next = available.findIndex((x) => x.name === store.current) + direction
if (next < 0) next = available.length - 1
if (next >= available.length) next = 0
const value = available[next]
if (!value) return
setStore("current", value.name) setStore("current", value.name)
if (value.model) if (value.model)
model.set({ model.set({
@@ -182,11 +199,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const current = createMemo(() => { const current = createMemo(() => {
const a = agent.current() const a = agent.current()
if (!a) return undefined
const key = getFirstValidModel( const key = getFirstValidModel(
() => ephemeral.model[a.name], () => ephemeral.model[a.name],
() => a.model, () => a.model,
fallbackModel, fallbackModel,
)! )
if (!key) return undefined
return find(key) return find(key)
}) })
@@ -232,7 +251,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
cycle, cycle,
set(model: ModelKey | undefined, options?: { recent?: boolean }) { set(model: ModelKey | undefined, options?: { recent?: boolean }) {
batch(() => { batch(() => {
setEphemeral("model", agent.current().name, model ?? fallbackModel()) const currentAgent = agent.current()
if (currentAgent) setEphemeral("model", currentAgent.name, model ?? fallbackModel())
if (model) updateVisibility(model, "show") if (model) updateVisibility(model, "show")
if (options?.recent && model) { if (options?.recent && model) {
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID) const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
@@ -377,17 +397,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
const list = async (path: string) => { const list = async (path: string) => {
return sdk.client.file.list({ path: path + "/" }).then((x) => { return sdk.client.file
setStore( .list({ path: path + "/" })
"node", .then((x) => {
produce((draft) => { setStore(
x.data!.forEach((node) => { "node",
if (node.path in draft) return produce((draft) => {
draft[node.path] = node x.data!.forEach((node) => {
}) if (node.path in draft) return
}), draft[node.path] = node
) })
}) }),
)
})
.catch(() => {})
} }
const searchFiles = (query: string) => sdk.client.find.files({ query, dirs: "false" }).then((x) => x.data!) const searchFiles = (query: string) => sdk.client.find.files({ query, dirs: "false" }).then((x) => x.data!)
+16 -9
View File
@@ -2,7 +2,9 @@ import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { useGlobalSync } from "./global-sync" import { useGlobalSync } from "./global-sync"
import { usePlatform } from "@/context/platform"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { base64Encode } from "@opencode-ai/util/encode"
import { EventSessionError } from "@opencode-ai/sdk/v2" import { EventSessionError } from "@opencode-ai/sdk/v2"
import { makeAudioPlayer } from "@solid-primitives/audio" import { makeAudioPlayer } from "@solid-primitives/audio"
import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac" import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
@@ -43,6 +45,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const platform = usePlatform()
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
"notification.v1", "notification.v1",
@@ -64,8 +67,8 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const sessionID = event.properties.sessionID const sessionID = event.properties.sessionID
const [syncStore] = globalSync.child(directory) const [syncStore] = globalSync.child(directory)
const match = Binary.search(syncStore.session, sessionID, (s) => s.id) const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
const isChild = match.found && syncStore.session[match.index].parentID const session = match.found ? syncStore.session[match.index] : undefined
if (isChild) break if (session?.parentID) break
try { try {
idlePlayer?.play() idlePlayer?.play()
} catch {} } catch {}
@@ -74,25 +77,29 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
type: "turn-complete", type: "turn-complete",
session: sessionID, session: sessionID,
}) })
const href = `/${base64Encode(directory)}/session/${sessionID}`
void platform.notify("Response ready", session?.title ?? sessionID, href)
break break
} }
case "session.error": { case "session.error": {
const sessionID = event.properties.sessionID const sessionID = event.properties.sessionID
if (sessionID) { const [syncStore] = globalSync.child(directory)
const [syncStore] = globalSync.child(directory) const match = sessionID ? Binary.search(syncStore.session, sessionID, (s) => s.id) : undefined
const match = Binary.search(syncStore.session, sessionID, (s) => s.id) const session = sessionID && match?.found ? syncStore.session[match.index] : undefined
const isChild = match.found && syncStore.session[match.index].parentID if (session?.parentID) break
if (isChild) break
}
try { try {
errorPlayer?.play() errorPlayer?.play()
} catch {} } catch {}
const error = "error" in event.properties ? event.properties.error : undefined
setStore("list", store.list.length, { setStore("list", store.list.length, {
...base, ...base,
type: "error", type: "error",
session: sessionID ?? "global", session: sessionID ?? "global",
error: "error" in event.properties ? event.properties.error : undefined, error,
}) })
const description = session?.title ?? (typeof error === "string" ? error : "An error occurred")
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
void platform.notify("Session error", description, href)
break break
} }
} }
+130
View File
@@ -0,0 +1,130 @@
import { createEffect, createRoot, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { Permission } from "@opencode-ai/sdk/v2/client"
import { persisted } from "@/utils/persist"
type PermissionsBySession = {
[sessionID: string]: Permission[]
}
type PermissionRespondFn = (input: {
sessionID: string
permissionID: string
response: "once" | "always" | "reject"
}) => void
const AUTO_ACCEPT_TYPES = new Set(["edit", "write"])
function shouldAutoAccept(perm: Permission) {
return AUTO_ACCEPT_TYPES.has(perm.type)
}
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
name: "Permission",
init: (props: { permissions: PermissionsBySession; onRespond: PermissionRespondFn }) => {
const [store, setStore, _, ready] = persisted(
"permission.v1",
createStore({
autoAcceptEdits: {} as Record<string, boolean>,
}),
)
const responded = new Set<string>()
const watches = new Map<string, () => void>()
function respond(perm: Permission) {
if (responded.has(perm.id)) return
responded.add(perm.id)
props.onRespond({
sessionID: perm.sessionID,
permissionID: perm.id,
response: "once",
})
}
function watch(sessionID: string) {
if (watches.has(sessionID)) return
const dispose = createRoot((dispose) => {
createEffect(() => {
if (!store.autoAcceptEdits[sessionID]) return
const permissions = props.permissions[sessionID] ?? []
permissions.length
for (const perm of permissions) {
if (!shouldAutoAccept(perm)) continue
respond(perm)
}
})
return dispose
})
watches.set(sessionID, dispose)
}
function unwatch(sessionID: string) {
const dispose = watches.get(sessionID)
if (!dispose) return
dispose()
watches.delete(sessionID)
}
createEffect(() => {
if (!ready()) return
for (const sessionID in store.autoAcceptEdits) {
if (!store.autoAcceptEdits[sessionID]) continue
watch(sessionID)
}
})
onCleanup(() => {
for (const dispose of watches.values()) dispose()
watches.clear()
})
function enable(sessionID: string) {
setStore("autoAcceptEdits", sessionID, true)
watch(sessionID)
const permissions = props.permissions[sessionID] ?? []
for (const perm of permissions) {
if (!shouldAutoAccept(perm)) continue
respond(perm)
}
}
function disable(sessionID: string) {
setStore("autoAcceptEdits", sessionID, false)
unwatch(sessionID)
}
return {
get permissions() {
return props.permissions
},
respond: props.onRespond,
isAutoAccepting(sessionID: string) {
return store.autoAcceptEdits[sessionID] ?? false
},
toggleAutoAccept(sessionID: string) {
if (store.autoAcceptEdits[sessionID]) {
disable(sessionID)
return
}
enable(sessionID)
},
enableAutoAccept(sessionID: string) {
if (store.autoAcceptEdits[sessionID]) return
enable(sessionID)
},
disableAutoAccept(sessionID: string) {
disable(sessionID)
},
}
},
})
+4 -1
View File
@@ -14,7 +14,10 @@ export type Platform = {
/** Restart the app */ /** Restart the app */
restart(): Promise<void> restart(): Promise<void>
/** Open native directory picker dialog (Tauri only) */ /** Send a system notification (optional deep link) */
notify(title: string, description?: string, href?: string): Promise<void>
/** Open directory picker dialog (native on Tauri, server-backed on web) */
openDirectoryPickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null> openDirectoryPickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null>
/** Open native file picker dialog (Tauri only) */ /** Open native file picker dialog (Tauri only) */
+183
View File
@@ -0,0 +1,183 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { persisted } from "@/utils/persist"
type StoredProject = { worktree: string; expanded: boolean }
export function normalizeServerUrl(input: string) {
const trimmed = input.trim()
if (!trimmed) return
const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`
const cleaned = withProtocol.replace(/\/+$/, "")
return cleaned.replace(/^(https?:\/\/[^/]+).*/, "$1")
}
export function serverDisplayName(url: string) {
if (!url) return ""
return url
.replace(/^https?:\/\//, "")
.replace(/\/+$/, "")
.split("/")[0]
}
function projectsKey(url: string) {
if (!url) return ""
const host = url.replace(/^https?:\/\//, "").split(":")[0]
if (host === "localhost" || host === "127.0.0.1") return "local"
return url
}
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server",
init: (props: { defaultUrl: string }) => {
const platform = usePlatform()
const [store, setStore, _, ready] = persisted(
"server.v3",
createStore({
list: [] as string[],
projects: {} as Record<string, StoredProject[]>,
}),
)
const [active, setActiveRaw] = createSignal("")
function setActive(input: string) {
const url = normalizeServerUrl(input)
if (!url) return
setActiveRaw(url)
}
function add(input: string) {
const url = normalizeServerUrl(input)
if (!url) return
const fallback = normalizeServerUrl(props.defaultUrl)
if (fallback && url === fallback) {
setActiveRaw(url)
return
}
batch(() => {
if (!store.list.includes(url)) {
setStore("list", store.list.length, url)
}
setActiveRaw(url)
})
}
function remove(input: string) {
const url = normalizeServerUrl(input)
if (!url) return
const list = store.list.filter((x) => x !== url)
const next = active() === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : active()
batch(() => {
setStore("list", list)
setActiveRaw(next)
})
}
createEffect(() => {
if (!ready()) return
if (active()) return
const url = normalizeServerUrl(props.defaultUrl)
if (!url) return
setActiveRaw(url)
})
const isReady = createMemo(() => ready() && !!active())
const [healthy, { refetch }] = createResource(
() => active() || undefined,
async (url) => {
if (!url) return
const sdk = createOpencodeClient({
baseUrl: url,
fetch: platform.fetch,
signal: AbortSignal.timeout(2000),
})
return sdk.global
.health()
.then((x) => x.data?.healthy === true)
.catch(() => false)
},
)
createEffect(() => {
if (!active()) return
const interval = setInterval(() => refetch(), 10_000)
onCleanup(() => clearInterval(interval))
})
const origin = createMemo(() => projectsKey(active()))
const projectsList = createMemo(() => store.projects[origin()] ?? [])
return {
ready: isReady,
healthy,
get url() {
return active()
},
get name() {
return serverDisplayName(active())
},
get list() {
return store.list
},
setActive,
add,
remove,
projects: {
list: projectsList,
open(directory: string) {
const key = origin()
if (!key) return
const current = store.projects[key] ?? []
if (current.find((x) => x.worktree === directory)) return
setStore("projects", key, [{ worktree: directory, expanded: true }, ...current])
},
close(directory: string) {
const key = origin()
if (!key) return
const current = store.projects[key] ?? []
setStore(
"projects",
key,
current.filter((x) => x.worktree !== directory),
)
},
expand(directory: string) {
const key = origin()
if (!key) return
const current = store.projects[key] ?? []
const index = current.findIndex((x) => x.worktree === directory)
if (index !== -1) setStore("projects", key, index, "expanded", true)
},
collapse(directory: string) {
const key = origin()
if (!key) return
const current = store.projects[key] ?? []
const index = current.findIndex((x) => x.worktree === directory)
if (index !== -1) setStore("projects", key, index, "expanded", false)
},
move(directory: string, toIndex: number) {
const key = origin()
if (!key) return
const current = store.projects[key] ?? []
const fromIndex = current.findIndex((x) => x.worktree === directory)
if (fromIndex === -1 || fromIndex === toIndex) return
const result = [...current]
const [item] = result.splice(fromIndex, 1)
result.splice(toIndex, 0, item)
setStore("projects", key, result)
},
},
}
},
})
+44 -20
View File
@@ -1,5 +1,5 @@
import { produce } from "solid-js/store" import { batch, createMemo } from "solid-js"
import { createMemo } from "solid-js" import { produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry" import { retry } from "@opencode-ai/util/retry"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
@@ -56,7 +56,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const result = Binary.search(messages, input.messageID, (m) => m.id) const result = Binary.search(messages, input.messageID, (m) => m.id)
messages.splice(result.index, 0, message) messages.splice(result.index, 0, message)
} }
draft.part[input.messageID] = input.parts.slice() draft.part[input.messageID] = input.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
}), }),
) )
}, },
@@ -67,22 +67,46 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
retry(() => sdk.client.session.todo({ sessionID })), retry(() => sdk.client.session.todo({ sessionID })),
retry(() => sdk.client.session.diff({ sessionID })), retry(() => sdk.client.session.diff({ sessionID })),
]) ])
setStore(
produce((draft) => { batch(() => {
const match = Binary.search(draft.session, sessionID, (s) => s.id) setStore(
if (match.found) draft.session[match.index] = session.data! "session",
if (!match.found) draft.session.splice(match.index, 0, session.data!) produce((draft) => {
draft.todo[sessionID] = todo.data ?? [] const match = Binary.search(draft, sessionID, (s) => s.id)
draft.message[sessionID] = messages if (match.found) {
.data!.map((x) => x.info) draft[match.index] = session.data!
.slice() return
.sort((a, b) => a.id.localeCompare(b.id)) }
for (const message of messages.data!) { draft.splice(match.index, 0, session.data!)
draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)) }),
} )
draft.session_diff[sessionID] = diff.data ?? []
}), setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
) setStore(
"message",
sessionID,
reconcile(
(messages.data ?? [])
.map((x) => x.info)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
for (const message of messages.data ?? []) {
setStore(
"part",
message.info.id,
reconcile(
message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
})
}, },
fetch: async (count = 10) => { fetch: async (count = 10) => {
setStore("limit", (x) => x + count) setStore("limit", (x) => x + count)
@@ -91,7 +115,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
.slice() .slice()
.sort((a, b) => a.id.localeCompare(b.id)) .sort((a, b) => a.id.localeCompare(b.id))
.slice(0, store.limit) .slice(0, store.limit)
setStore("session", sessions) setStore("session", reconcile(sessions, { key: "id" }))
}) })
}, },
more: createMemo(() => store.session.length >= store.limit), more: createMemo(() => store.session.length >= store.limit),
+38 -22
View File
@@ -36,35 +36,49 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
all: createMemo(() => Object.values(store.all)), all: createMemo(() => Object.values(store.all)),
active: createMemo(() => store.active), active: createMemo(() => store.active),
new() { new() {
sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => { sdk.client.pty
const id = pty.data?.id .create({ title: `Terminal ${store.all.length + 1}` })
if (!id) return .then((pty) => {
setStore("all", [ const id = pty.data?.id
...store.all, if (!id) return
{ setStore("all", [
id, ...store.all,
title: pty.data?.title ?? "Terminal", {
}, id,
]) title: pty.data?.title ?? "Terminal",
setStore("active", id) },
}) ])
setStore("active", id)
})
.catch((e) => {
console.error("Failed to create terminal", e)
})
}, },
update(pty: Partial<LocalPTY> & { id: string }) { update(pty: Partial<LocalPTY> & { id: string }) {
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
sdk.client.pty.update({ sdk.client.pty
ptyID: pty.id, .update({
title: pty.title, ptyID: pty.id,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, title: pty.title,
}) size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
.catch((e) => {
console.error("Failed to update terminal", e)
})
}, },
async clone(id: string) { async clone(id: string) {
const index = store.all.findIndex((x) => x.id === id) const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index] const pty = store.all[index]
if (!pty) return if (!pty) return
const clone = await sdk.client.pty.create({ const clone = await sdk.client.pty
title: pty.title, .create({
}) title: pty.title,
if (!clone.data) return })
.catch((e) => {
console.error("Failed to clone terminal", e)
return undefined
})
if (!clone?.data) return
setStore("all", index, { setStore("all", index, {
...pty, ...pty,
...clone.data, ...clone.data,
@@ -88,7 +102,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
setStore("active", previous?.id) setStore("active", previous?.id)
} }
}) })
await sdk.client.pty.remove({ ptyID: id }) await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
console.error("Failed to close terminal", e)
})
}, },
move(id: string, to: number) { move(id: string, to: number) {
const index = store.all.findIndex((f) => f.id === id) const index = store.all.findIndex((f) => f.id === id)
+30
View File
@@ -20,6 +20,36 @@ const platform: Platform = {
restart: async () => { restart: async () => {
window.location.reload() window.location.reload()
}, },
notify: async (title, description, href) => {
if (!("Notification" in window)) return
const permission =
Notification.permission === "default"
? await Notification.requestPermission().catch(() => "denied")
: Notification.permission
if (permission !== "granted") return
const inView = document.visibilityState === "visible" && document.hasFocus()
if (inView) return
await Promise.resolve()
.then(() => {
const notification = new Notification(title, {
body: description ?? "",
icon: "https://opencode.ai/favicon-96x96.png",
})
notification.onclick = () => {
window.focus()
if (href) {
window.history.pushState(null, "", href)
window.dispatchEvent(new PopStateEvent("popstate"))
}
notification.close()
}
})
.catch(() => undefined)
},
} }
render( render(
+14 -4
View File
@@ -1,8 +1,9 @@
import { createMemo, Show, type ParentProps } from "solid-js" import { createMemo, Show, type ParentProps } from "solid-js"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { SDKProvider } from "@/context/sdk" import { SDKProvider, useSDK } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync" import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local" import { LocalProvider } from "@/context/local"
import { PermissionProvider } from "@/context/permission"
import { base64Decode } from "@opencode-ai/util/encode" import { base64Decode } from "@opencode-ai/util/encode"
import { DataProvider } from "@opencode-ai/ui/context" import { DataProvider } from "@opencode-ai/ui/context"
import { iife } from "@opencode-ai/util/iife" import { iife } from "@opencode-ai/util/iife"
@@ -18,10 +19,19 @@ export default function Layout(props: ParentProps) {
<SyncProvider> <SyncProvider>
{iife(() => { {iife(() => {
const sync = useSync() const sync = useSync()
const sdk = useSDK()
const respond = (input: {
sessionID: string
permissionID: string
response: "once" | "always" | "reject"
}) => sdk.client.permission.respond(input)
return ( return (
<DataProvider data={sync.data} directory={directory()}> <PermissionProvider permissions={sync.data.permission} onRespond={respond}>
<LocalProvider>{props.children}</LocalProvider> <DataProvider data={sync.data} directory={directory()} onPermissionRespond={respond}>
</DataProvider> <LocalProvider>{props.children}</LocalProvider>
</DataProvider>
</PermissionProvider>
) )
})} })}
</SyncProvider> </SyncProvider>
+83 -20
View File
@@ -20,11 +20,51 @@ function isInitError(error: unknown): error is InitError {
) )
} }
function safeJson(value: unknown): string {
const seen = new WeakSet<object>()
const json = JSON.stringify(
value,
(_key, val) => {
if (typeof val === "bigint") return val.toString()
if (typeof val === "object" && val) {
if (seen.has(val)) return "[Circular]"
seen.add(val)
}
return val
},
2,
)
return json ?? String(value)
}
function formatInitError(error: InitError): string { function formatInitError(error: InitError): string {
const data = error.data const data = error.data
switch (error.name) { switch (error.name) {
case "MCPFailed": case "MCPFailed":
return `MCP server "${data.name}" failed. Note, opencode does not support MCP authentication yet.` return `MCP server "${data.name}" failed. Note, opencode does not support MCP authentication yet.`
case "ProviderAuthError": {
const providerID = typeof data.providerID === "string" ? data.providerID : "unknown"
const message = typeof data.message === "string" ? data.message : safeJson(data.message)
return `Provider authentication failed (${providerID}): ${message}`
}
case "APIError": {
const message = typeof data.message === "string" ? data.message : "API error"
const lines: string[] = [message]
if (typeof data.statusCode === "number") {
lines.push(`Status: ${data.statusCode}`)
}
if (typeof data.isRetryable === "boolean") {
lines.push(`Retryable: ${data.isRetryable}`)
}
if (typeof data.responseBody === "string" && data.responseBody) {
lines.push(`Response body:\n${data.responseBody}`)
}
return lines.join("\n")
}
case "ProviderModelNotFoundError": { case "ProviderModelNotFoundError": {
const { providerID, modelID, suggestions } = data as { const { providerID, modelID, suggestions } = data as {
providerID: string providerID: string
@@ -37,10 +77,14 @@ function formatInitError(error: InitError): string {
`Check your config (opencode.json) provider/model names`, `Check your config (opencode.json) provider/model names`,
].join("\n") ].join("\n")
} }
case "ProviderInitError": case "ProviderInitError": {
return `Failed to initialize provider "${data.providerID}". Check credentials and configuration.` const providerID = typeof data.providerID === "string" ? data.providerID : "unknown"
case "ConfigJsonError": return `Failed to initialize provider "${providerID}". Check credentials and configuration.`
return `Config file at ${data.path} is not valid JSON(C)` + (data.message ? `: ${data.message}` : "") }
case "ConfigJsonError": {
const message = typeof data.message === "string" ? data.message : ""
return `Config file at ${data.path} is not valid JSON(C)` + (message ? `: ${message}` : "")
}
case "ConfigDirectoryTypoError": case "ConfigDirectoryTypoError":
return `Directory "${data.dir}" in ${data.path} is not valid. Rename the directory to "${data.suggestion}" or remove it. This is a common typo.` return `Directory "${data.dir}" in ${data.path} is not valid. Rename the directory to "${data.suggestion}" or remove it. This is a common typo.`
case "ConfigFrontmatterError": case "ConfigFrontmatterError":
@@ -51,14 +95,14 @@ function formatInitError(error: InitError): string {
(issue: { message: string; path: string[] }) => "↳ " + issue.message + " " + issue.path.join("."), (issue: { message: string; path: string[] }) => "↳ " + issue.message + " " + issue.path.join("."),
) )
: [] : []
return [`Config file at ${data.path} is invalid` + (data.message ? `: ${data.message}` : ""), ...issues].join( const message = typeof data.message === "string" ? data.message : ""
"\n", return [`Config file at ${data.path} is invalid` + (message ? `: ${message}` : ""), ...issues].join("\n")
)
} }
case "UnknownError": case "UnknownError":
return String(data.message) return typeof data.message === "string" ? data.message : safeJson(data)
default: default:
return data.message ? String(data.message) : JSON.stringify(data, null, 2) if (typeof data.message === "string") return data.message
return safeJson(data)
} }
} }
@@ -69,7 +113,7 @@ function formatErrorChain(error: unknown, depth = 0, parentMessage?: string): st
const message = formatInitError(error) const message = formatInitError(error)
if (depth > 0 && parentMessage === message) return "" if (depth > 0 && parentMessage === message) return ""
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : "" const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
return indent + message return indent + `${error.name}\n${message}`
} }
if (error instanceof Error) { if (error instanceof Error) {
@@ -77,15 +121,34 @@ function formatErrorChain(error: unknown, depth = 0, parentMessage?: string): st
const parts: string[] = [] const parts: string[] = []
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : "" const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
if (!isDuplicate) { const header = `${error.name}${error.message ? `: ${error.message}` : ""}`
// Stack already includes error name and message, so prefer it const stack = error.stack?.trim()
parts.push(indent + (error.stack ?? `${error.name}: ${error.message}`))
} else if (error.stack) { if (stack) {
// Duplicate message - only show the stack trace lines (skip message) const startsWithHeader = stack.startsWith(header)
const trace = error.stack.split("\n").slice(1).join("\n").trim()
if (trace) { if (isDuplicate && startsWithHeader) {
parts.push(trace) const trace = stack.split("\n").slice(1).join("\n").trim()
if (trace) {
parts.push(indent + trace)
}
} }
if (isDuplicate && !startsWithHeader) {
parts.push(indent + stack)
}
if (!isDuplicate && startsWithHeader) {
parts.push(indent + stack)
}
if (!isDuplicate && !startsWithHeader) {
parts.push(indent + `${header}\n${stack}`)
}
}
if (!stack && !isDuplicate) {
parts.push(indent + header)
} }
if (error.cause) { if (error.cause) {
@@ -105,7 +168,7 @@ function formatErrorChain(error: unknown, depth = 0, parentMessage?: string): st
} }
const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : "" const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
return indent + JSON.stringify(error, null, 2) return indent + safeJson(error)
} }
function formatError(error: unknown): string { function formatError(error: unknown): string {
@@ -131,7 +194,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
readOnly readOnly
copyable copyable
multiline multiline
class="max-h-96 w-full font-mono text-xs no-scrollbar whitespace-pre" class="max-h-96 w-full font-mono text-xs no-scrollbar"
label="Error Details" label="Error Details"
hideLabel hideLabel
/> />
+48 -19
View File
@@ -8,12 +8,18 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { DateTime } from "luxon" import { DateTime } from "luxon"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { useServer } from "@/context/server"
export default function Home() { export default function Home() {
const sync = useGlobalSync() const sync = useGlobalSync()
const layout = useLayout() const layout = useLayout()
const platform = usePlatform() const platform = usePlatform()
const dialog = useDialog()
const navigate = useNavigate() const navigate = useNavigate()
const server = useServer()
const homedir = createMemo(() => sync.data.path.home) const homedir = createMemo(() => sync.data.path.home)
function openProject(directory: string) { function openProject(directory: string) {
@@ -22,32 +28,57 @@ export default function Home() {
} }
async function chooseProject() { async function chooseProject() {
const result = await platform.openDirectoryPickerDialog?.({ function resolve(result: string | string[] | null) {
title: "Open project", if (Array.isArray(result)) {
multiple: true, for (const directory of result) {
}) openProject(directory)
if (Array.isArray(result)) { }
for (const directory of result) { } else if (result) {
openProject(directory) openProject(result)
} }
} else if (result) { }
openProject(result)
if (platform.openDirectoryPickerDialog) {
const result = await platform.openDirectoryPickerDialog?.({
title: "Open project",
multiple: true,
})
resolve(result)
} else {
dialog.show(
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
() => resolve(null),
)
} }
} }
return ( return (
<div class="mx-auto mt-55"> <div class="mx-auto mt-55">
<Logo class="w-xl opacity-12" /> <Logo class="w-xl opacity-12" />
<Button
size="large"
variant="ghost"
class="mt-4 mx-auto text-14-regular text-text-weak"
onClick={() => dialog.show(() => <DialogSelectServer />)}
>
<div
classList={{
"size-2 rounded-full": true,
"bg-icon-success-base": server.healthy() === true,
"bg-icon-critical-base": server.healthy() === false,
"bg-border-weak-base": server.healthy() === undefined,
}}
/>
{server.name}
</Button>
<Switch> <Switch>
<Match when={sync.data.project.length > 0}> <Match when={sync.data.project.length > 0}>
<div class="mt-20 w-full flex flex-col gap-4"> <div class="mt-20 w-full flex flex-col gap-4">
<div class="flex gap-2 items-center justify-between pl-3"> <div class="flex gap-2 items-center justify-between pl-3">
<div class="text-14-medium text-text-strong">Recent projects</div> <div class="text-14-medium text-text-strong">Recent projects</div>
<Show when={platform.openDirectoryPickerDialog}> <Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
<Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}> Open project
Open project </Button>
</Button>
</Show>
</div> </div>
<ul class="flex flex-col gap-2"> <ul class="flex flex-col gap-2">
<For <For
@@ -80,11 +111,9 @@ export default function Home() {
<div class="text-12-regular text-text-weak">Get started by opening a local project</div> <div class="text-12-regular text-text-weak">Get started by opening a local project</div>
</div> </div>
<div /> <div />
<Show when={platform.openDirectoryPickerDialog}> <Button class="px-3" onClick={chooseProject}>
<Button class="px-3" onClick={chooseProject}> Open project
Open project </Button>
</Button>
</Show>
</div> </div>
</Match> </Match>
</Switch> </Switch>
+282 -92
View File
@@ -41,15 +41,19 @@ import {
} from "@thisbeyond/solid-dnd" } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { showToast, Toast } from "@opencode-ai/ui/toast" import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { useNotification } from "@/context/notification" import { useNotification } from "@/context/notification"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { Header } from "@/components/header" import { Header } from "@/components/header"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { DialogSelectProvider } from "@/components/dialog-select-provider" import { DialogSelectProvider } from "@/components/dialog-select-provider"
import { useCommand } from "@/context/command" import { DialogEditProject } from "@/components/dialog-edit-project"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd" import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
export default function Layout(props: ParentProps) { export default function Layout(props: ParentProps) {
const [store, setStore] = createStore({ const [store, setStore] = createStore({
@@ -89,6 +93,41 @@ export default function Layout(props: ParentProps) {
const providers = useProviders() const providers = useProviders()
const dialog = useDialog() const dialog = useDialog()
const command = useCommand() const command = useCommand()
const theme = useTheme()
const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
const colorSchemeLabel: Record<ColorScheme, string> = {
system: "System",
light: "Light",
dark: "Dark",
}
function cycleTheme(direction = 1) {
const ids = availableThemeEntries().map(([id]) => id)
if (ids.length === 0) return
const currentIndex = ids.indexOf(theme.themeId())
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
const nextThemeId = ids[nextIndex]
theme.setTheme(nextThemeId)
const nextTheme = theme.themes()[nextThemeId]
showToast({
title: "Theme switched",
description: nextTheme?.name ?? nextThemeId,
})
}
function cycleColorScheme(direction = 1) {
const current = theme.colorScheme()
const currentIndex = colorSchemeOrder.indexOf(current)
const nextIndex =
currentIndex === -1 ? 0 : (currentIndex + direction + colorSchemeOrder.length) % colorSchemeOrder.length
const next = colorSchemeOrder[nextIndex]
theme.setColorScheme(next)
showToast({
title: "Color scheme",
description: colorSchemeLabel[next],
})
}
onMount(async () => { onMount(async () => {
if (platform.checkUpdate && platform.update && platform.restart) { if (platform.checkUpdate && platform.update && platform.restart) {
@@ -117,6 +156,77 @@ export default function Layout(props: ParentProps) {
} }
}) })
onMount(() => {
const seenSessions = new Set<string>()
const toastBySession = new Map<string, number>()
const unsub = globalSDK.event.listen((e) => {
if (e.details?.type !== "permission.updated") return
const directory = e.name
const permission = e.details.properties
const currentDir = params.dir ? base64Decode(params.dir) : undefined
const currentSession = params.id
const [store] = globalSync.child(directory)
const session = store.session.find((s) => s.id === permission.sessionID)
const sessionTitle = session?.title ?? "New session"
const projectName = getFilename(directory)
const description = `${sessionTitle} in ${projectName} needs permission`
const href = `/${base64Encode(directory)}/session/${permission.sessionID}`
void platform.notify("Permission required", description, href)
if (directory === currentDir && permission.sessionID === currentSession) return
if (directory === currentDir && session?.parentID === currentSession) return
const sessionKey = `${directory}:${permission.sessionID}`
if (seenSessions.has(sessionKey)) return
seenSessions.add(sessionKey)
const toastId = showToast({
persistent: true,
icon: "checklist",
title: "Permission required",
description,
actions: [
{
label: "Go to session",
onClick: () => {
navigate(href)
},
},
{
label: "Dismiss",
onClick: "dismiss",
},
],
})
toastBySession.set(sessionKey, toastId)
})
onCleanup(unsub)
createEffect(() => {
const currentDir = params.dir ? base64Decode(params.dir) : undefined
const currentSession = params.id
if (!currentDir || !currentSession) return
const sessionKey = `${currentDir}:${currentSession}`
const toastId = toastBySession.get(sessionKey)
if (toastId !== undefined) {
toaster.dismiss(toastId)
toastBySession.delete(sessionKey)
seenSessions.delete(sessionKey)
}
const [store] = globalSync.child(currentDir)
const childSessions = store.session.filter((s) => s.parentID === currentSession)
for (const child of childSessions) {
const childKey = `${currentDir}:${child.id}`
const childToastId = toastBySession.get(childKey)
if (childToastId !== undefined) {
toaster.dismiss(childToastId)
toastBySession.delete(childKey)
seenSessions.delete(childKey)
}
}
})
})
function sortSessions(a: Session, b: Session) { function sortSessions(a: Session, b: Session) {
const now = Date.now() const now = Date.now()
const oneMinuteAgo = now - 60 * 1000 const oneMinuteAgo = now - 60 * 1000
@@ -221,62 +331,113 @@ export default function Layout(props: ParentProps) {
} }
} }
command.register(() => [ command.register(() => {
{ const commands: CommandOption[] = [
id: "sidebar.toggle", {
title: "Toggle sidebar", id: "sidebar.toggle",
category: "View", title: "Toggle sidebar",
keybind: "mod+b", category: "View",
onSelect: () => layout.sidebar.toggle(), keybind: "mod+b",
}, onSelect: () => layout.sidebar.toggle(),
...(platform.openDirectoryPickerDialog
? [
{
id: "project.open",
title: "Open project",
category: "Project",
keybind: "mod+o",
onSelect: () => chooseProject(),
},
]
: []),
{
id: "provider.connect",
title: "Connect provider",
category: "Provider",
onSelect: () => connectProvider(),
},
{
id: "session.previous",
title: "Previous session",
category: "Session",
keybind: "alt+arrowup",
onSelect: () => navigateSessionByOffset(-1),
},
{
id: "session.next",
title: "Next session",
category: "Session",
keybind: "alt+arrowdown",
onSelect: () => navigateSessionByOffset(1),
},
{
id: "session.archive",
title: "Archive session",
category: "Session",
keybind: "mod+shift+backspace",
disabled: !params.dir || !params.id,
onSelect: () => {
const session = currentSessions().find((s) => s.id === params.id)
if (session) archiveSession(session)
}, },
}, {
]) id: "project.open",
title: "Open project",
category: "Project",
keybind: "mod+o",
onSelect: () => chooseProject(),
},
{
id: "provider.connect",
title: "Connect provider",
category: "Provider",
onSelect: () => connectProvider(),
},
{
id: "server.switch",
title: "Switch server",
category: "Server",
onSelect: () => openServer(),
},
{
id: "session.previous",
title: "Previous session",
category: "Session",
keybind: "alt+arrowup",
onSelect: () => navigateSessionByOffset(-1),
},
{
id: "session.next",
title: "Next session",
category: "Session",
keybind: "alt+arrowdown",
onSelect: () => navigateSessionByOffset(1),
},
{
id: "session.archive",
title: "Archive session",
category: "Session",
keybind: "mod+shift+backspace",
disabled: !params.dir || !params.id,
onSelect: () => {
const session = currentSessions().find((s) => s.id === params.id)
if (session) archiveSession(session)
},
},
{
id: "theme.cycle",
title: "Cycle theme",
category: "Theme",
keybind: "mod+shift+t",
onSelect: () => cycleTheme(1),
},
]
for (const [id, definition] of availableThemeEntries()) {
commands.push({
id: `theme.set.${id}`,
title: `Use theme: ${definition.name ?? id}`,
category: "Theme",
onSelect: () => theme.commitPreview(),
onHighlight: () => {
theme.previewTheme(id)
return () => theme.cancelPreview()
},
})
}
commands.push({
id: "theme.scheme.cycle",
title: "Cycle color scheme",
category: "Theme",
keybind: "mod+shift+s",
onSelect: () => cycleColorScheme(1),
})
for (const scheme of colorSchemeOrder) {
commands.push({
id: `theme.scheme.${scheme}`,
title: `Use color scheme: ${colorSchemeLabel[scheme]}`,
category: "Theme",
onSelect: () => theme.commitPreview(),
onHighlight: () => {
theme.previewColorScheme(scheme)
return () => theme.cancelPreview()
},
})
}
return commands
})
function connectProvider() { function connectProvider() {
dialog.show(() => <DialogSelectProvider />) dialog.show(() => <DialogSelectProvider />)
} }
function openServer() {
dialog.show(() => <DialogSelectServer />)
}
function navigateToProject(directory: string | undefined) { function navigateToProject(directory: string | undefined) {
if (!directory) return if (!directory) return
const lastSession = store.lastSession[directory] const lastSession = store.lastSession[directory]
@@ -304,17 +465,28 @@ export default function Layout(props: ParentProps) {
} }
async function chooseProject() { async function chooseProject() {
const result = await platform.openDirectoryPickerDialog?.({ function resolve(result: string | string[] | null) {
title: "Open project", if (Array.isArray(result)) {
multiple: true, for (const directory of result) {
}) openProject(directory, false)
if (Array.isArray(result)) { }
for (const directory of result) { navigateToProject(result[0])
openProject(directory, false) } else if (result) {
openProject(result)
} }
navigateToProject(result[0]) }
} else if (result) {
openProject(result) if (platform.openDirectoryPickerDialog) {
const result = await platform.openDirectoryPickerDialog?.({
title: "Open project",
multiple: true,
})
resolve(result)
} else {
dialog.show(
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
() => resolve(null),
)
} }
} }
@@ -376,7 +548,7 @@ export default function Layout(props: ParentProps) {
const notification = useNotification() const notification = useNotification()
const notifications = createMemo(() => notification.project.unseen(props.project.worktree)) const notifications = createMemo(() => notification.project.unseen(props.project.worktree))
const hasError = createMemo(() => notifications().some((n) => n.type === "error")) const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
const name = createMemo(() => getFilename(props.project.worktree)) const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)" const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)"
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
@@ -412,7 +584,7 @@ export default function Layout(props: ParentProps) {
} }
const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => { const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => {
const name = createMemo(() => getFilename(props.project.worktree)) const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
const current = createMemo(() => base64Decode(params.dir ?? "")) const current = createMemo(() => base64Decode(params.dir ?? ""))
return ( return (
<Switch> <Switch>
@@ -454,8 +626,20 @@ export default function Layout(props: ParentProps) {
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated)) const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
const notifications = createMemo(() => notification.session.unseen(props.session.id)) const notifications = createMemo(() => notification.session.unseen(props.session.id))
const hasError = createMemo(() => notifications().some((n) => n.type === "error")) const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
const hasPermissions = createMemo(() => {
const store = globalSync.child(props.project.worktree)[0]
const permissions = store.permission?.[props.session.id] ?? []
if (permissions.length > 0) return true
const childSessions = store.session.filter((s) => s.parentID === props.session.id)
for (const child of childSessions) {
const childPermissions = store.permission?.[child.id] ?? []
if (childPermissions.length > 0) return true
}
return false
})
const isWorking = createMemo(() => { const isWorking = createMemo(() => {
if (props.session.id === params.id) return false if (props.session.id === params.id) return false
if (hasPermissions()) return false
const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id] const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id]
return status?.type === "busy" || status?.type === "retry" return status?.type === "busy" || status?.type === "retry"
}) })
@@ -486,6 +670,9 @@ export default function Layout(props: ParentProps) {
<Match when={isWorking()}> <Match when={isWorking()}>
<Spinner class="size-2.5 mr-0.5" /> <Spinner class="size-2.5 mr-0.5" />
</Match> </Match>
<Match when={hasPermissions()}>
<div class="size-1.5 mr-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={hasError()}> <Match when={hasError()}>
<div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" /> <div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
</Match> </Match>
@@ -540,7 +727,7 @@ export default function Layout(props: ParentProps) {
const sortable = createSortable(props.project.worktree) const sortable = createSortable(props.project.worktree)
const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened()) const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened())
const slug = createMemo(() => base64Encode(props.project.worktree)) const slug = createMemo(() => base64Encode(props.project.worktree))
const name = createMemo(() => getFilename(props.project.worktree)) const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
const [store, setProjectStore] = globalSync.child(props.project.worktree) const [store, setProjectStore] = globalSync.child(props.project.worktree)
const sessions = createMemo(() => store.session.toSorted(sortSessions)) const sessions = createMemo(() => store.session.toSorted(sortSessions))
const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID)) const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
@@ -586,8 +773,13 @@ export default function Layout(props: ParentProps) {
<DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" /> <DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" />
<DropdownMenu.Portal> <DropdownMenu.Portal>
<DropdownMenu.Content> <DropdownMenu.Content>
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogEditProject project={props.project} />)}
>
<DropdownMenu.ItemLabel>Edit project</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => closeProject(props.project.worktree)}> <DropdownMenu.Item onSelect={() => closeProject(props.project.worktree)}>
<DropdownMenu.ItemLabel>Close Project</DropdownMenu.ItemLabel> <DropdownMenu.ItemLabel>Close project</DropdownMenu.ItemLabel>
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Portal> </DropdownMenu.Portal>
@@ -749,7 +941,7 @@ export default function Layout(props: ParentProps) {
</div> </div>
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3"> <div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
<Switch> <Switch>
<Match when={!providers.paid().length && expanded()}> <Match when={providers.all().length > 0 && !providers.paid().length && expanded()}>
<div class="rounded-md bg-background-stronger shadow-xs-border-base"> <div class="rounded-md bg-background-stronger shadow-xs-border-base">
<div class="p-3 flex flex-col gap-2"> <div class="p-3 flex flex-col gap-2">
<div class="text-12-medium text-text-strong">Getting started</div> <div class="text-12-medium text-text-strong">Getting started</div>
@@ -768,7 +960,7 @@ export default function Layout(props: ParentProps) {
</Tooltip> </Tooltip>
</div> </div>
</Match> </Match>
<Match when={true}> <Match when={providers.all().length > 0}>
<Tooltip placement="right" value="Connect provider" inactive={expanded()}> <Tooltip placement="right" value="Connect provider" inactive={expanded()}>
<Button <Button
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2" class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
@@ -782,30 +974,28 @@ export default function Layout(props: ParentProps) {
</Tooltip> </Tooltip>
</Match> </Match>
</Switch> </Switch>
<Show when={platform.openDirectoryPickerDialog}> <Tooltip
<Tooltip placement="right"
placement="right" value={
value={ <div class="flex items-center gap-2">
<div class="flex items-center gap-2"> <span>Open project</span>
<span>Open project</span> <Show when={!sidebarProps.mobile}>
<Show when={!sidebarProps.mobile}> <span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
<span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span> </Show>
</Show> </div>
</div> }
} inactive={expanded()}
inactive={expanded()} >
<Button
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}
> >
<Button <Show when={expanded()}>Open project</Show>
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2" </Button>
variant="ghost" </Tooltip>
size="large"
icon="folder-add-left"
onClick={chooseProject}
>
<Show when={expanded()}>Open project</Show>
</Button>
</Tooltip>
</Show>
<Tooltip placement="right" value="Share feedback" inactive={expanded()}> <Tooltip placement="right" value="Share feedback" inactive={expanded()}>
<Button <Button
as={"a"} as={"a"}
@@ -825,7 +1015,7 @@ export default function Layout(props: ParentProps) {
} }
return ( return (
<div class="relative flex-1 min-h-0 flex flex-col"> <div class="relative flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<Header <Header
navigateToProject={navigateToProject} navigateToProject={navigateToProject}
navigateToSession={navigateToSession} navigateToSession={navigateToSession}
+104 -22
View File
@@ -49,6 +49,7 @@ import { checksum } from "@opencode-ai/util/encode"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file" import { DialogSelectFile } from "@/components/dialog-select-file"
import { DialogSelectModel } from "@/components/dialog-select-model" import { DialogSelectModel } from "@/components/dialog-select-model"
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
import { useCommand } from "@/context/command" import { useCommand } from "@/context/command"
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { UserMessage } from "@opencode-ai/sdk/v2" import { UserMessage } from "@opencode-ai/sdk/v2"
@@ -56,6 +57,17 @@ import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt" import { usePrompt } from "@/context/prompt"
import { extractPromptFromParts } from "@/utils/prompt" import { extractPromptFromParts } from "@/utils/prompt"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { StatusBar } from "@/components/status-bar"
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
import { usePermission } from "@/context/permission"
import { showToast } from "@opencode-ai/ui/toast"
function same<T>(a: readonly T[], b: readonly T[]) {
if (a === b) return true
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}
export default function Page() { export default function Page() {
const layout = useLayout() const layout = useLayout()
@@ -70,22 +82,40 @@ export default function Page() {
const sdk = useSDK() const sdk = useSDK()
const prompt = usePrompt() const prompt = usePrompt()
const permission = usePermission()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey())) const tabs = createMemo(() => layout.tabs(sessionKey()))
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const revertMessageID = createMemo(() => info()?.revert?.messageID) const revertMessageID = createMemo(() => info()?.revert?.messageID)
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const userMessages = createMemo(() => const emptyUserMessages: UserMessage[] = []
messages() const userMessages = createMemo(
.filter((m) => m.role === "user") () => messages().filter((m) => m.role === "user") as UserMessage[],
.sort((a, b) => a.id.localeCompare(b.id)), emptyUserMessages,
{ equals: same },
)
const visibleUserMessages = createMemo(
() => {
const revert = revertMessageID()
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
},
emptyUserMessages,
{ equals: same },
)
const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
createEffect(
on(
() => lastUserMessage()?.id,
() => {
const msg = lastUserMessage()
if (!msg) return
if (msg.agent) local.agent.set(msg.agent)
if (msg.model) local.model.set(msg.model)
},
),
) )
const visibleUserMessages = createMemo(() => {
const revert = revertMessageID()
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
})
const lastUserMessage = createMemo(() => visibleUserMessages()?.at(-1))
const [store, setStore] = createStore({ const [store, setStore] = createStore({
clickTimer: undefined as number | undefined, clickTimer: undefined as number | undefined,
@@ -155,16 +185,37 @@ export default function Page() {
), ),
) )
createEffect(() => { const idle = { type: "idle" as const }
params.id
const status = sync.data.session_status[params.id ?? ""] ?? { type: "idle" } createEffect(
batch(() => { on(
setStore("userInteracted", false) () => params.id,
setStore("stepsExpanded", status.type !== "idle") (id) => {
}) const status = sync.data.session_status[id ?? ""] ?? idle
}) batch(() => {
setStore("userInteracted", false)
setStore("stepsExpanded", status.type !== "idle")
})
},
),
)
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
createEffect(
on(
() => status().type,
(type) => {
if (type !== "idle") return
batch(() => {
setStore("userInteracted", false)
setStore("stepsExpanded", false)
})
},
{ defer: true },
),
)
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? { type: "idle" })
const working = createMemo(() => status().type !== "idle" && activeMessage()?.id === lastUserMessage()?.id) const working = createMemo(() => status().type !== "idle" && activeMessage()?.id === lastUserMessage()?.id)
createRenderEffect((prev) => { createRenderEffect((prev) => {
@@ -274,6 +325,15 @@ export default function Page() {
slash: "model", slash: "model",
onSelect: () => dialog.show(() => <DialogSelectModel />), onSelect: () => dialog.show(() => <DialogSelectModel />),
}, },
{
id: "mcp.toggle",
title: "Toggle MCPs",
description: "Toggle MCPs",
category: "MCP",
keybind: "mod+;",
slash: "mcp",
onSelect: () => dialog.show(() => <DialogSelectMcp />),
},
{ {
id: "agent.cycle", id: "agent.cycle",
title: "Cycle agent", title: "Cycle agent",
@@ -291,6 +351,22 @@ export default function Page() {
keybind: "shift+mod+.", keybind: "shift+mod+.",
onSelect: () => local.agent.move(-1), onSelect: () => local.agent.move(-1),
}, },
{
id: "permissions.autoaccept",
title: params.id && permission.isAutoAccepting(params.id) ? "Stop auto-accepting edits" : "Auto-accept edits",
category: "Permissions",
disabled: !params.id,
onSelect: () => {
if (!params.id) return
permission.toggleAutoAccept(params.id)
showToast({
title: permission.isAutoAccepting(params.id) ? "Auto-accepting edits" : "Stopped auto-accepting edits",
description: permission.isAutoAccepting(params.id)
? "Edit and write permissions will be automatically approved"
: "Edit and write permissions will require approval",
})
},
},
{ {
id: "session.undo", id: "session.undo",
title: "Undo", title: "Undo",
@@ -562,6 +638,7 @@ export default function Page() {
<SessionTurn <SessionTurn
sessionID={params.id!} sessionID={params.id!}
messageID={message.id} messageID={message.id}
lastUserMessageID={lastUserMessage()?.id}
stepsExpanded={store.mobileStepsExpanded[message.id] ?? false} stepsExpanded={store.mobileStepsExpanded[message.id] ?? false}
onStepsExpandedToggle={() => setStore("mobileStepsExpanded", message.id, (x) => !x)} onStepsExpandedToggle={() => setStore("mobileStepsExpanded", message.id, (x) => !x)}
onUserInteracted={() => setStore("userInteracted", true)} onUserInteracted={() => setStore("userInteracted", true)}
@@ -618,6 +695,7 @@ export default function Page() {
<SessionTurn <SessionTurn
sessionID={params.id!} sessionID={params.id!}
messageID={activeMessage()!.id} messageID={activeMessage()!.id}
lastUserMessageID={lastUserMessage()?.id}
stepsExpanded={store.stepsExpanded} stepsExpanded={store.stepsExpanded}
onStepsExpandedToggle={() => setStore("stepsExpanded", (x) => !x)} onStepsExpandedToggle={() => setStore("stepsExpanded", (x) => !x)}
onUserInteracted={() => setStore("userInteracted", true)} onUserInteracted={() => setStore("userInteracted", true)}
@@ -780,7 +858,7 @@ export default function Page() {
</Tabs.List> </Tabs.List>
</div> </div>
<Show when={diffs().length}> <Show when={diffs().length}>
<Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden contain-strict"> <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden"> <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionReview <SessionReview
classes={{ classes={{
@@ -806,7 +884,7 @@ export default function Page() {
}, },
) )
return ( return (
<Tabs.Content value={tab} class="select-text mt-3"> <Tabs.Content value={tab} class="mt-3">
<Switch> <Switch>
<Match when={file()}> <Match when={file()}>
{(f) => ( {(f) => (
@@ -818,7 +896,7 @@ export default function Page() {
cacheKey: checksum(f().content?.content ?? ""), cacheKey: checksum(f().content?.content ?? ""),
}} }}
overflow="scroll" overflow="scroll"
class="pb-40" class="select-text pb-40"
/> />
)} )}
</Match> </Match>
@@ -921,6 +999,10 @@ export default function Page() {
</DragDropProvider> </DragDropProvider>
</div> </div>
</Show> </Show>
<StatusBar>
<SessionLspIndicator />
<SessionMcpIndicator />
</StatusBar>
</div> </div>
) )
} }
+1 -1
View File
@@ -10,6 +10,6 @@ export default defineConfig({
}, },
build: { build: {
target: "esnext", target: "esnext",
sourcemap: true, // sourcemap: true,
}, },
}) })
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.0.202", "version": "1.0.217",
"type": "module", "type": "module",
"scripts": { "scripts": {
"typecheck": "tsgo --noEmit", "typecheck": "tsgo --noEmit",
@@ -5,28 +5,36 @@ import { useAuthSession } from "~/context/auth.session"
export async function GET(input: APIEvent) { export async function GET(input: APIEvent) {
const url = new URL(input.request.url) const url = new URL(input.request.url)
const code = url.searchParams.get("code") try {
if (!code) throw new Error("No code found") const code = url.searchParams.get("code")
const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`) if (!code) throw new Error("No code found")
if (result.err) { const result = await AuthClient.exchange(code, `${url.origin}${url.pathname}`)
throw new Error(result.err.message) if (result.err) throw new Error(result.err.message)
} const decoded = AuthClient.decode(result.tokens.access, {} as any)
const decoded = AuthClient.decode(result.tokens.access, {} as any) if (decoded.err) throw new Error(decoded.err.message)
if (decoded.err) throw new Error(decoded.err.message) const session = await useAuthSession()
const session = await useAuthSession() const id = decoded.subject.properties.accountID
const id = decoded.subject.properties.accountID await session.update((value) => {
await session.update((value) => { return {
return { ...value,
...value, account: {
account: { ...value.account,
...value.account, [id]: {
[id]: { id,
id, email: decoded.subject.properties.email,
email: decoded.subject.properties.email, },
}, },
}, current: id,
current: id, }
} })
}) return redirect("/auth")
return redirect("/auth") } catch (e: any) {
return new Response(
JSON.stringify({
error: e.message,
cause: Object.fromEntries(url.searchParams.entries()),
}),
{ status: 500 },
)
}
} }
@@ -0,0 +1,365 @@
import { Title } from "@solidjs/meta"
import { createAsync, query, useParams } from "@solidjs/router"
import { createSignal, For, Show } from "solid-js"
import { Database, desc, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
interface TaskSource {
repo: string
from: string
to: string
}
interface Judge {
score: number
rationale: string
judge: string
}
interface ScoreDetail {
criterion: string
weight: number
average: number
variance?: number
judges?: Judge[]
}
interface RunUsage {
input: number
output: number
cost: number
}
interface Run {
task: string
model: string
agent: string
score: {
final: number
base: number
penalty: number
}
scoreDetails: ScoreDetail[]
usage?: RunUsage
duration?: number
}
interface Prompt {
commit: string
prompt: string
}
interface AverageUsage {
input: number
output: number
cost: number
}
interface Task {
averageScore: number
averageDuration?: number
averageUsage?: AverageUsage
model?: string
agent?: string
summary?: string
runs?: Run[]
task: {
id: string
source: TaskSource
prompts?: Prompt[]
}
}
interface BenchmarkResult {
averageScore: number
tasks: Task[]
}
async function getTaskDetail(benchmarkId: string, taskId: string) {
"use server"
const rows = await Database.use((tx) =>
tx.select().from(BenchmarkTable).where(eq(BenchmarkTable.id, benchmarkId)).limit(1),
)
if (!rows[0]) return null
const parsed = JSON.parse(rows[0].result) as BenchmarkResult
const task = parsed.tasks.find((t) => t.task.id === taskId)
return task ?? null
}
const queryTaskDetail = query(getTaskDetail, "benchmark.task.detail")
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000)
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
if (minutes > 0) {
return `${minutes}m ${remainingSeconds}s`
}
return `${remainingSeconds}s`
}
export default function BenchDetail() {
const params = useParams()
const [benchmarkId, taskId] = (params.id ?? "").split(":")
const task = createAsync(() => queryTaskDetail(benchmarkId, taskId))
return (
<main data-page="bench-detail">
<Title>Benchmark - {taskId}</Title>
<div style={{ padding: "1rem" }}>
<Show when={task()} fallback={<p>Task not found</p>}>
<div style={{ "margin-bottom": "1rem" }}>
<div>
<strong>Agent: </strong>
{task()?.agent ?? "N/A"}
</div>
<div>
<strong>Model: </strong>
{task()?.model ?? "N/A"}
</div>
<div>
<strong>Task: </strong>
{task()!.task.id}
</div>
</div>
<div style={{ "margin-bottom": "1rem" }}>
<div>
<strong>Repo: </strong>
<a
href={`https://github.com/${task()!.task.source.repo}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: "#0066cc" }}
>
{task()!.task.source.repo}
</a>
</div>
<div>
<strong>From: </strong>
<a
href={`https://github.com/${task()!.task.source.repo}/commit/${task()!.task.source.from}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: "#0066cc" }}
>
{task()!.task.source.from.slice(0, 7)}
</a>
</div>
<div>
<strong>To: </strong>
<a
href={`https://github.com/${task()!.task.source.repo}/commit/${task()!.task.source.to}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: "#0066cc" }}
>
{task()!.task.source.to.slice(0, 7)}
</a>
</div>
</div>
<Show when={task()?.task.prompts && task()!.task.prompts!.length > 0}>
<div style={{ "margin-bottom": "1rem" }}>
<strong>Prompt:</strong>
<For each={task()!.task.prompts}>
{(p) => (
<div style={{ "margin-top": "0.5rem" }}>
<div style={{ "font-size": "0.875rem", color: "#666" }}>Commit: {p.commit.slice(0, 7)}</div>
<p style={{ "margin-top": "0.25rem", "white-space": "pre-wrap" }}>{p.prompt}</p>
</div>
)}
</For>
</div>
</Show>
<hr style={{ margin: "1rem 0", border: "none", "border-top": "1px solid #ccc" }} />
<div style={{ "margin-bottom": "1rem" }}>
<div>
<strong>Average Duration: </strong>
{task()?.averageDuration ? formatDuration(task()!.averageDuration!) : "N/A"}
</div>
<div>
<strong>Average Score: </strong>
{task()?.averageScore?.toFixed(3) ?? "N/A"}
</div>
<div>
<strong>Average Cost: </strong>
{task()?.averageUsage?.cost ? `$${task()!.averageUsage!.cost.toFixed(4)}` : "N/A"}
</div>
</div>
<Show when={task()?.summary}>
<div style={{ "margin-bottom": "1rem" }}>
<strong>Summary:</strong>
<p style={{ "margin-top": "0.5rem", "white-space": "pre-wrap" }}>{task()!.summary}</p>
</div>
</Show>
<Show when={task()?.runs && task()!.runs!.length > 0}>
<div style={{ "margin-bottom": "1rem" }}>
<strong>Runs:</strong>
<table style={{ "margin-top": "0.5rem", "border-collapse": "collapse", width: "100%" }}>
<thead>
<tr>
<th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>Run</th>
<th
style={{
border: "1px solid #ccc",
padding: "0.5rem",
"text-align": "left",
"white-space": "nowrap",
}}
>
Score (Base - Penalty)
</th>
<th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>Cost</th>
<th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>Duration</th>
<For each={task()!.runs![0]?.scoreDetails}>
{(detail) => (
<th style={{ border: "1px solid #ccc", padding: "0.5rem", "text-align": "left" }}>
{detail.criterion} ({detail.weight})
</th>
)}
</For>
</tr>
</thead>
<tbody>
<For each={task()!.runs}>
{(run, index) => (
<tr>
<td style={{ border: "1px solid #ccc", padding: "0.5rem" }}>{index() + 1}</td>
<td style={{ border: "1px solid #ccc", padding: "0.5rem", "white-space": "nowrap" }}>
{run.score.final.toFixed(3)} ({run.score.base.toFixed(3)} - {run.score.penalty.toFixed(3)})
</td>
<td style={{ border: "1px solid #ccc", padding: "0.5rem" }}>
{run.usage?.cost ? `$${run.usage.cost.toFixed(4)}` : "N/A"}
</td>
<td style={{ border: "1px solid #ccc", padding: "0.5rem" }}>
{run.duration ? formatDuration(run.duration) : "N/A"}
</td>
<For each={run.scoreDetails}>
{(detail) => (
<td style={{ border: "1px solid #ccc", padding: "0.5rem" }}>
<For each={detail.judges}>
{(judge) => (
<span
style={{
color: judge.score === 1 ? "green" : judge.score === 0 ? "red" : "inherit",
"margin-right": "0.25rem",
}}
>
{judge.score === 1 ? "✓" : judge.score === 0 ? "✗" : judge.score}
</span>
)}
</For>
</td>
)}
</For>
</tr>
)}
</For>
</tbody>
</table>
<For each={task()!.runs}>
{(run, index) => (
<div style={{ "margin-top": "1rem" }}>
<h3 style={{ margin: "0 0 0.5rem 0" }}>Run {index() + 1}</h3>
<div>
<strong>Score: </strong>
{run.score.final.toFixed(3)} (Base: {run.score.base.toFixed(3)} - Penalty:{" "}
{run.score.penalty.toFixed(3)})
</div>
<For each={run.scoreDetails}>
{(detail) => (
<div style={{ "margin-top": "1rem", "padding-left": "1rem", "border-left": "2px solid #ccc" }}>
<div>
{detail.criterion} (weight: {detail.weight}){" "}
<For each={detail.judges}>
{(judge) => (
<span
style={{
color: judge.score === 1 ? "green" : judge.score === 0 ? "red" : "inherit",
"margin-right": "0.25rem",
}}
>
{judge.score === 1 ? "✓" : judge.score === 0 ? "✗" : judge.score}
</span>
)}
</For>
</div>
<Show when={detail.judges && detail.judges.length > 0}>
<For each={detail.judges}>
{(judge) => {
const [expanded, setExpanded] = createSignal(false)
return (
<div style={{ "margin-top": "0.5rem", "padding-left": "1rem" }}>
<div
style={{ "font-size": "0.875rem", cursor: "pointer" }}
onClick={() => setExpanded(!expanded())}
>
<span style={{ "margin-right": "0.5rem" }}>{expanded() ? "▼" : "▶"}</span>
<span
style={{
color: judge.score === 1 ? "green" : judge.score === 0 ? "red" : "inherit",
}}
>
{judge.score === 1 ? "✓" : judge.score === 0 ? "✗" : judge.score}
</span>{" "}
{judge.judge}
</div>
<Show when={expanded()}>
<p
style={{
margin: "0.25rem 0 0 0",
"white-space": "pre-wrap",
"font-size": "0.875rem",
}}
>
{judge.rationale}
</p>
</Show>
</div>
)
}}
</For>
</Show>
</div>
)}
</For>
</div>
)}
</For>
</div>
</Show>
{(() => {
const [jsonExpanded, setJsonExpanded] = createSignal(false)
return (
<div style={{ "margin-top": "1rem" }}>
<button
style={{
cursor: "pointer",
padding: "0.75rem 1.5rem",
"font-size": "1rem",
background: "#f0f0f0",
border: "1px solid #ccc",
"border-radius": "4px",
}}
onClick={() => setJsonExpanded(!jsonExpanded())}
>
<span style={{ "margin-right": "0.5rem" }}>{jsonExpanded() ? "▼" : "▶"}</span>
Raw JSON
</button>
<Show when={jsonExpanded()}>
<pre>{JSON.stringify(task(), null, 2)}</pre>
</Show>
</div>
)
})()}
</Show>
</div>
</main>
)
}
@@ -0,0 +1,86 @@
import { Title } from "@solidjs/meta"
import { A, createAsync, query } from "@solidjs/router"
import { createMemo, For, Show } from "solid-js"
import { Database, desc } from "@opencode-ai/console-core/drizzle/index.js"
import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
interface BenchmarkResult {
averageScore: number
tasks: { averageScore: number; task: { id: string } }[]
}
async function getBenchmarks() {
"use server"
const rows = await Database.use((tx) =>
tx.select().from(BenchmarkTable).orderBy(desc(BenchmarkTable.timeCreated)).limit(100),
)
return rows.map((row) => {
const parsed = JSON.parse(row.result) as BenchmarkResult
const taskScores: Record<string, number> = {}
for (const t of parsed.tasks) {
taskScores[t.task.id] = t.averageScore
}
return {
id: row.id,
agent: row.agent,
model: row.model,
averageScore: parsed.averageScore,
taskScores,
}
})
}
const queryBenchmarks = query(getBenchmarks, "benchmarks.list")
export default function Bench() {
const benchmarks = createAsync(() => queryBenchmarks())
const taskIds = createMemo(() => {
const ids = new Set<string>()
for (const row of benchmarks() ?? []) {
for (const id of Object.keys(row.taskScores)) {
ids.add(id)
}
}
return [...ids].sort()
})
return (
<main data-page="bench" style={{ padding: "2rem" }}>
<Title>Benchmark</Title>
<h1 style={{ "margin-bottom": "1.5rem" }}>Benchmarks</h1>
<table style={{ "border-collapse": "collapse", width: "100%" }}>
<thead>
<tr>
<th style={{ "text-align": "left", padding: "0.75rem" }}>Agent</th>
<th style={{ "text-align": "left", padding: "0.75rem" }}>Model</th>
<th style={{ "text-align": "left", padding: "0.75rem" }}>Score</th>
<For each={taskIds()}>{(id) => <th style={{ "text-align": "left", padding: "0.75rem" }}>{id}</th>}</For>
</tr>
</thead>
<tbody>
<For each={benchmarks()}>
{(row) => (
<tr>
<td style={{ padding: "0.75rem" }}>{row.agent}</td>
<td style={{ padding: "0.75rem" }}>{row.model}</td>
<td style={{ padding: "0.75rem" }}>{row.averageScore.toFixed(3)}</td>
<For each={taskIds()}>
{(id) => (
<td style={{ padding: "0.75rem" }}>
<Show when={row.taskScores[id] !== undefined} fallback="">
<A href={`/bench/${row.id}:${id}`} style={{ color: "#0066cc" }}>
{row.taskScores[id]?.toFixed(3)}
</A>
</Show>
</td>
)}
</For>
</tr>
)}
</For>
</tbody>
</table>
</main>
)
}
@@ -0,0 +1,29 @@
import type { APIEvent } from "@solidjs/start/server"
import { Database } from "@opencode-ai/console-core/drizzle/index.js"
import { BenchmarkTable } from "@opencode-ai/console-core/schema/benchmark.sql.js"
import { Identifier } from "@opencode-ai/console-core/identifier.js"
interface SubmissionBody {
model: string
agent: string
result: string
}
export async function POST(event: APIEvent) {
const body = (await event.request.json()) as SubmissionBody
if (!body.model || !body.agent || !body.result) {
return Response.json({ error: "All fields are required" }, { status: 400 })
}
await Database.use((tx) =>
tx.insert(BenchmarkTable).values({
id: Identifier.create("benchmark"),
model: body.model,
agent: body.agent,
result: body.result,
}),
)
return Response.json({ success: true }, { status: 200 })
}
@@ -124,6 +124,8 @@ export async function handler(
res.status !== 200 && res.status !== 200 &&
// ie. openai 404 error: Item with id 'msg_0ead8b004a3b165d0069436a6b6834819896da85b63b196a3f' not found. // ie. openai 404 error: Item with id 'msg_0ead8b004a3b165d0069436a6b6834819896da85b63b196a3f' not found.
res.status !== 404 && res.status !== 404 &&
// ie. cannot change codex model providers mid-session
!modelInfo.stickyProvider &&
modelInfo.fallbackProvider && modelInfo.fallbackProvider &&
providerInfo.id !== modelInfo.fallbackProvider providerInfo.id !== modelInfo.fallbackProvider
) { ) {
@@ -0,0 +1,12 @@
CREATE TABLE `benchmark` (
`id` varchar(30) NOT NULL,
`time_created` timestamp(3) NOT NULL DEFAULT (now()),
`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
`time_deleted` timestamp(3),
`model` varchar(64) NOT NULL,
`agent` varchar(64) NOT NULL,
`result` mediumtext NOT NULL,
CONSTRAINT `benchmark_id_pk` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE INDEX `time_created` ON `benchmark` (`time_created`);
File diff suppressed because it is too large Load Diff
@@ -274,6 +274,13 @@
"when": 1764110043942, "when": 1764110043942,
"tag": "0038_famous_magik", "tag": "0038_famous_magik",
"breakpoints": true "breakpoints": true
},
{
"idx": 39,
"version": "5",
"when": 1766946179892,
"tag": "0039_striped_forge",
"breakpoints": true
} }
] ]
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.0.202", "version": "1.0.217",
"private": true, "private": true,
"type": "module", "type": "module",
"dependencies": { "dependencies": {
+1
View File
@@ -5,6 +5,7 @@ export namespace Identifier {
const prefixes = { const prefixes = {
account: "acc", account: "acc",
auth: "aut", auth: "aut",
benchmark: "ben",
billing: "bil", billing: "bil",
key: "key", key: "key",
model: "mod", model: "mod",
@@ -0,0 +1,14 @@
import { index, mediumtext, mysqlTable, primaryKey, varchar } from "drizzle-orm/mysql-core"
import { id, timestamps } from "../drizzle/types"
export const BenchmarkTable = mysqlTable(
"benchmark",
{
id: id(),
...timestamps,
model: varchar("model", { length: 64 }).notNull(),
agent: varchar("agent", { length: 64 }).notNull(),
result: mediumtext("result").notNull(),
},
(table) => [primaryKey({ columns: [table.id] }), index("time_created").on(table.timeCreated)],
)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.0.202", "version": "1.0.217",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+5 -1
View File
@@ -123,7 +123,11 @@ export default {
}, },
}).then((x) => x.json())) as any }).then((x) => x.json())) as any
subject = user.id.toString() subject = user.id.toString()
email = emails.find((x: any) => x.primary && x.verified)?.email
const primaryEmail = emails.find((x: any) => x.primary)
if (!primaryEmail) throw new Error("No primary email found for GitHub user")
if (!primaryEmail.verified) throw new Error("Primary email for GitHub user not verified")
email = primaryEmail.email
} else if (response.provider === "google") { } else if (response.provider === "google") {
if (!response.id.email_verified) throw new Error("Google email not verified") if (!response.id.email_verified) throw new Error("Google email not verified")
subject = response.id.sub as string subject = response.id.sub as string
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.0.202", "version": "1.0.217",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+31 -6
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" style="background-color: var(--background-base)">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -13,14 +13,39 @@
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" /> <meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta property="og:image" content="/social-share.png" /> <meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" /> <meta property="twitter:image" content="/social-share.png" />
</head> <!-- Theme preload script - applies cached theme to avoid FOUC -->
<body class="antialiased overscroll-none text-12-regular overflow-hidden"> <script id="oc-theme-preload-script">
<script>
;(function () { ;(function () {
const savedTheme = localStorage.getItem("theme") || "oc-1" var themeId = localStorage.getItem("opencode-theme-id")
document.documentElement.setAttribute("data-theme", savedTheme) if (!themeId) return
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
var mode = isDark ? "dark" : "light"
document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode
if (themeId === "oc-1") return
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
if (css) {
var style = document.createElement("style")
style.id = "oc-theme-preload"
style.textContent =
":root{color-scheme:" +
mode +
";--text-mix-blend-mode:" +
(isDark ? "plus-lighter" : "multiply") +
";" +
css +
"}"
document.head.appendChild(style)
}
})() })()
</script> </script>
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-screen"></div> <div id="root" class="flex flex-col h-screen"></div>
<script src="/src/index.tsx" type="module"></script> <script src="/src/index.tsx" type="module"></script>
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.0.202", "version": "1.0.217",
"type": "module", "type": "module",
"scripts": { "scripts": {
"typecheck": "tsgo -b", "typecheck": "tsgo -b",
@@ -18,6 +18,7 @@
"@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-dialog": "~2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "~2", "@tauri-apps/plugin-os": "~2",
"@tauri-apps/plugin-notification": "~2",
"@tauri-apps/plugin-process": "~2", "@tauri-apps/plugin-process": "~2",
"@tauri-apps/plugin-shell": "~2", "@tauri-apps/plugin-shell": "~2",
"@tauri-apps/plugin-store": "~2", "@tauri-apps/plugin-store": "~2",
+58
View File
@@ -2210,6 +2210,18 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mac-notification-sys"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621"
dependencies = [
"cc",
"objc2 0.6.3",
"objc2-foundation 0.3.2",
"time",
]
[[package]] [[package]]
name = "markup5ever" name = "markup5ever"
version = "0.14.1" version = "0.14.1"
@@ -2384,6 +2396,20 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "notify-rust"
version = "4.11.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6442248665a5aa2514e794af3b39661a8e73033b1cc5e59899e1276117ee4400"
dependencies = [
"futures-lite",
"log",
"mac-notification-sys",
"serde",
"tauri-winrt-notification",
"zbus",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.1.0"
@@ -2758,6 +2784,7 @@ dependencies = [
"tauri-plugin-clipboard-manager", "tauri-plugin-clipboard-manager",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-http", "tauri-plugin-http",
"tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-os", "tauri-plugin-os",
"tauri-plugin-process", "tauri-plugin-process",
@@ -4519,6 +4546,25 @@ dependencies = [
"urlpattern", "urlpattern",
] ]
[[package]]
name = "tauri-plugin-notification"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
dependencies = [
"log",
"notify-rust",
"rand 0.9.2",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"thiserror 2.0.17",
"time",
"url",
]
[[package]] [[package]]
name = "tauri-plugin-opener" name = "tauri-plugin-opener"
version = "2.5.2" version = "2.5.2"
@@ -4754,6 +4800,18 @@ dependencies = [
"toml 0.9.8", "toml 0.9.8",
] ]
[[package]]
name = "tauri-winrt-notification"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
dependencies = [
"quick-xml 0.37.5",
"thiserror 2.0.17",
"windows",
"windows-version",
]
[[package]] [[package]]
name = "tempfile" name = "tempfile"
version = "3.23.0" version = "3.23.0"
+1
View File
@@ -28,6 +28,7 @@ tauri-plugin-store = "2"
tauri-plugin-window-state = "2" tauri-plugin-window-state = "2"
tauri-plugin-clipboard-manager = "2" tauri-plugin-clipboard-manager = "2"
tauri-plugin-http = "2" tauri-plugin-http = "2"
tauri-plugin-notification = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
@@ -8,6 +8,10 @@
"opener:default", "opener:default",
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"core:webview:allow-set-webview-zoom", "core:webview:allow-set-webview-zoom",
"core:window:allow-is-focused",
"core:window:allow-show",
"core:window:allow-unminimize",
"core:window:allow-set-focus",
"shell:default", "shell:default",
"updater:default", "updater:default",
"dialog:default", "dialog:default",
@@ -15,6 +19,7 @@
"store:default", "store:default",
"window-state:default", "window-state:default",
"os:default", "os:default",
"notification:default",
{ {
"identifier": "http:default", "identifier": "http:default",
"allow": [{ "url": "http://*" }, { "url": "https://*" }, { "url": "http://*:*/*" }] "allow": [{ "url": "http://*" }, { "url": "https://*" }, { "url": "http://*:*/*" }]
+1
View File
@@ -198,6 +198,7 @@ pub fn run() {
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_notification::init())
.plugin(PinchZoomDisablePlugin) .plugin(PinchZoomDisablePlugin)
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
kill_sidecar, kill_sidecar,
+29
View File
@@ -12,6 +12,8 @@ import { UPDATER_ENABLED } from "./updater"
import { createMenu } from "./menu" import { createMenu } from "./menu"
import { check, Update } from "@tauri-apps/plugin-updater" import { check, Update } from "@tauri-apps/plugin-updater"
import { invoke } from "@tauri-apps/api/core" import { invoke } from "@tauri-apps/api/core"
import { getCurrentWindow } from "@tauri-apps/api/window"
import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
import { relaunch } from "@tauri-apps/plugin-process" import { relaunch } from "@tauri-apps/plugin-process"
import pkg from "../package.json" import pkg from "../package.json"
@@ -94,6 +96,33 @@ const platform: Platform = {
await relaunch() await relaunch()
}, },
notify: async (title, description, href) => {
const granted = await isPermissionGranted().catch(() => false)
const permission = granted ? "granted" : await requestPermission().catch(() => "denied")
if (permission !== "granted") return
const win = getCurrentWindow()
const focused = await win.isFocused().catch(() => document.hasFocus())
if (focused) return
await Promise.resolve()
.then(() => {
const notification = new Notification(title, { body: description ?? "" })
notification.onclick = () => {
const win = getCurrentWindow()
void win.show().catch(() => undefined)
void win.unminimize().catch(() => undefined)
void win.setFocus().catch(() => undefined)
if (href) {
window.history.pushState(null, "", href)
window.dispatchEvent(new PopStateEvent("popstate"))
}
notification.close()
}
})
.catch(() => undefined)
},
// @ts-expect-error // @ts-expect-error
fetch: tauriFetch, fetch: tauriFetch,
} }
+7
View File
@@ -10,6 +10,13 @@ export default defineConfig({
// //
// 1. prevent Vite from obscuring rust errors // 1. prevent Vite from obscuring rust errors
clearScreen: false, clearScreen: false,
esbuild: {
// Improves production stack traces
keepNames: true,
},
// build: {
// sourcemap: true,
// },
// 2. tauri expects a fixed port, fail if that port is not available // 2. tauri expects a fixed port, fail if that port is not available
server: { server: {
port: 1420, port: 1420,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.0.202", "version": "1.0.217",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+8 -5
View File
@@ -3,6 +3,7 @@ import { FileRoutes } from "@solidjs/start/router"
import { Font } from "@opencode-ai/ui/font" import { Font } from "@opencode-ai/ui/font"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { Suspense } from "solid-js" import { Suspense } from "solid-js"
import "./app.css" import "./app.css"
import { Favicon } from "@opencode-ai/ui/favicon" import { Favicon } from "@opencode-ai/ui/favicon"
@@ -12,11 +13,13 @@ export default function App() {
<Router <Router
root={(props) => ( root={(props) => (
<MetaProvider> <MetaProvider>
<MarkedProvider> <DialogProvider>
<Favicon /> <MarkedProvider>
<Font /> <Favicon />
<Suspense>{props.children}</Suspense> <Font />
</MarkedProvider> <Suspense>{props.children}</Suspense>
</MarkedProvider>
</DialogProvider>
</MetaProvider> </MetaProvider>
)} )}
> >
@@ -162,11 +162,20 @@ export default function () {
return ( return (
<ErrorBoundary <ErrorBoundary
fallback={(e) => { fallback={(error) => {
if (SessionDataMissingError.isInstance(error)) {
return <NotFound />
}
console.error(error)
const details = error instanceof Error ? (error.stack ?? error.message) : String(error)
return ( return (
<Show when={e.message === "SessionDataMissingError"}> <div class="min-h-screen w-full bg-background-base text-text-base flex flex-col items-center justify-center gap-4 p-6 text-center">
<NotFound /> <p class="text-16-medium">Unable to render this share.</p>
</Show> <p class="text-14-regular text-text-weaker">Check the console for more details.</p>
<pre class="text-12-mono text-left whitespace-pre-wrap break-words w-full max-w-200 bg-background-stronger rounded-md p-4">
{details}
</pre>
</div>
) )
}} }}
> >
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.0.202" version = "1.0.217"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/sst/opencode" repository = "https://github.com/sst/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.202/opencode-darwin-arm64.zip" archive = "https://github.com/sst/opencode/releases/download/v1.0.217/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.202/opencode-darwin-x64.zip" archive = "https://github.com/sst/opencode/releases/download/v1.0.217/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.202/opencode-linux-arm64.tar.gz" archive = "https://github.com/sst/opencode/releases/download/v1.0.217/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.202/opencode-linux-x64.tar.gz" archive = "https://github.com/sst/opencode/releases/download/v1.0.217/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.202/opencode-windows-x64.zip" archive = "https://github.com/sst/opencode/releases/download/v1.0.217/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.0.202", "version": "1.0.217",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+3
View File
@@ -2,3 +2,6 @@ preload = ["@opentui/solid/preload"]
[test] [test]
preload = ["./test/preload.ts"] preload = ["./test/preload.ts"]
timeout = 10000 # 10 seconds (default is 5000ms)
# Enable code coverage
coverage = true
+16 -9
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.0.202", "version": "1.0.217",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"private": true, "private": true,
@@ -50,16 +50,22 @@
"@actions/github": "6.0.1", "@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.5.1", "@agentclientprotocol/sdk": "0.5.1",
"@ai-sdk/amazon-bedrock": "3.0.57", "@ai-sdk/amazon-bedrock": "3.0.57",
"@ai-sdk/anthropic": "2.0.50", "@ai-sdk/anthropic": "2.0.56",
"@ai-sdk/azure": "2.0.73", "@ai-sdk/azure": "2.0.82",
"@ai-sdk/google": "2.0.44", "@ai-sdk/cerebras": "1.0.33",
"@ai-sdk/cohere": "2.0.21",
"@ai-sdk/deepinfra": "1.0.30",
"@ai-sdk/gateway": "2.0.23",
"@ai-sdk/google": "2.0.49",
"@ai-sdk/google-vertex": "3.0.81", "@ai-sdk/google-vertex": "3.0.81",
"@ai-sdk/mcp": "0.0.8", "@ai-sdk/groq": "2.0.33",
"@ai-sdk/mistral": "2.0.26", "@ai-sdk/mistral": "2.0.26",
"@ai-sdk/openai": "2.0.71", "@ai-sdk/openai": "2.0.71",
"@ai-sdk/openai-compatible": "1.0.27", "@ai-sdk/openai-compatible": "1.0.29",
"@ai-sdk/perplexity": "2.0.22",
"@ai-sdk/provider": "2.0.0", "@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.18", "@ai-sdk/provider-utils": "3.0.19",
"@ai-sdk/togetherai": "1.0.30",
"@ai-sdk/xai": "2.0.42", "@ai-sdk/xai": "2.0.42",
"@clack/prompts": "1.0.0-alpha.1", "@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
@@ -73,14 +79,15 @@
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2", "@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.63", "@opentui/core": "0.1.67",
"@opentui/solid": "0.1.63", "@opentui/solid": "0.1.67",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
"@standard-schema/spec": "1.0.0", "@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62", "@zip.js/zip.js": "2.7.62",
"ai": "catalog:", "ai": "catalog:",
"bonjour-service": "1.3.0",
"bun-pty": "0.4.2", "bun-pty": "0.4.2",
"chokidar": "4.0.3", "chokidar": "4.0.3",
"clipboardy": "4.0.0", "clipboardy": "4.0.0",
+14
View File
@@ -235,5 +235,19 @@ export default {
], ],
}, },
}, },
{
filetype: "nix",
// TODO: Replace with official tree-sitter-nix WASM when published
// See: https://github.com/nix-community/tree-sitter-nix/issues/66
wasm: "https://github.com/ast-grep/ast-grep.github.io/raw/40b84530640aa83a0d34a20a2b0623d7b8e5ea97/website/public/parsers/tree-sitter-nix.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/nix/highlights.scm",
],
locals: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/nix/locals.scm",
],
},
},
], ],
} }
@@ -7,4 +7,5 @@ Rules:
- Do not explain what the user asked for - Do not explain what the user asked for
- Write in first person (I added..., I fixed...) - Write in first person (I added..., I fixed...)
- Never ask questions or add new questions - Never ask questions or add new questions
- Only exception: if the conversation ends with an unanswered question to the user, preserve that exact question - If the conversation ends with an unanswered question to the user, preserve that exact question
- If the conversation ends with an imperative statement or request to the user (e.g. "Now please run the command and paste the console output"), always include that exact request in the summary
+1 -1
View File
@@ -22,7 +22,7 @@ Your output must be:
- The title should NEVER include "summarizing" or "generating" when generating a title - The title should NEVER include "summarizing" or "generating" when generating a title
- DO NOT SAY YOU CANNOT GENERATE A TITLE OR COMPLAIN ABOUT THE INPUT - DO NOT SAY YOU CANNOT GENERATE A TITLE OR COMPLAIN ABOUT THE INPUT
- Always output something meaningful, even if the input is minimal. - Always output something meaningful, even if the input is minimal.
- If the user message is short or conversational (e.g. "hello", "lol", "whats up", "hey"): - If the user message is short or conversational (e.g. "hello", "lol", "what's up", "hey"):
→ create a title that reflects the user's tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.) → create a title that reflects the user's tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.)
</rules> </rules>
+8 -20
View File
@@ -5,6 +5,7 @@ import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"
import { ACP } from "@/acp/agent" import { ACP } from "@/acp/agent"
import { Server } from "@/server/server" import { Server } from "@/server/server"
import { createOpencodeClient } from "@opencode-ai/sdk/v2" import { createOpencodeClient } from "@opencode-ai/sdk/v2"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
const log = Log.create({ service: "acp-command" }) const log = Log.create({ service: "acp-command" })
@@ -19,29 +20,16 @@ export const AcpCommand = cmd({
command: "acp", command: "acp",
describe: "start ACP (Agent Client Protocol) server", describe: "start ACP (Agent Client Protocol) server",
builder: (yargs) => { builder: (yargs) => {
return yargs return withNetworkOptions(yargs).option("cwd", {
.option("cwd", { describe: "working directory",
describe: "working directory", type: "string",
type: "string", default: process.cwd(),
default: process.cwd(), })
})
.option("port", {
type: "number",
describe: "port to listen on",
default: 0,
})
.option("hostname", {
type: "string",
describe: "hostname to listen on",
default: "127.0.0.1",
})
}, },
handler: async (args) => { handler: async (args) => {
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
const server = Server.listen({ const opts = await resolveNetworkOptions(args)
port: args.port, const server = Server.listen(opts)
hostname: args.hostname,
})
const sdk = createOpencodeClient({ const sdk = createOpencodeClient({
baseUrl: `http://${server.hostname}:${server.port}`, baseUrl: `http://${server.hostname}:${server.port}`,
+6
View File
@@ -349,6 +349,12 @@ export const AuthLoginCommand = cmd({
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token") prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
} }
if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
prompts.log.info(
"Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
)
}
const key = await prompts.password({ const key = await prompts.password({
message: "Enter your API key", message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"), validate: (x) => (x && x.length > 0 ? undefined : "Required"),
+118 -30
View File
@@ -9,7 +9,9 @@ import * as github from "@actions/github"
import type { Context } from "@actions/github/lib/context" import type { Context } from "@actions/github/lib/context"
import type { import type {
IssueCommentEvent, IssueCommentEvent,
IssuesEvent,
PullRequestReviewCommentEvent, PullRequestReviewCommentEvent,
WorkflowDispatchEvent,
WorkflowRunEvent, WorkflowRunEvent,
PullRequestEvent, PullRequestEvent,
} from "@octokit/webhooks-types" } from "@octokit/webhooks-types"
@@ -132,7 +134,16 @@ type IssueQueryResponse = {
const AGENT_USERNAME = "opencode-agent[bot]" const AGENT_USERNAME = "opencode-agent[bot]"
const AGENT_REACTION = "eyes" const AGENT_REACTION = "eyes"
const WORKFLOW_FILE = ".github/workflows/opencode.yml" const WORKFLOW_FILE = ".github/workflows/opencode.yml"
const SUPPORTED_EVENTS = ["issue_comment", "pull_request_review_comment", "schedule", "pull_request"] as const
// Event categories for routing
// USER_EVENTS: triggered by user actions, have actor/issueId, support reactions/comments
// REPO_EVENTS: triggered by automation, no actor/issueId, output to logs/PR only
const USER_EVENTS = ["issue_comment", "pull_request_review_comment", "issues", "pull_request"] as const
const REPO_EVENTS = ["schedule", "workflow_dispatch"] as const
const SUPPORTED_EVENTS = [...USER_EVENTS, ...REPO_EVENTS] as const
type UserEvent = (typeof USER_EVENTS)[number]
type RepoEvent = (typeof REPO_EVENTS)[number]
// Parses GitHub remote URLs in various formats: // Parses GitHub remote URLs in various formats:
// - https://github.com/owner/repo.git // - https://github.com/owner/repo.git
@@ -147,6 +158,29 @@ export function parseGitHubRemote(url: string): { owner: string; repo: string }
return { owner: match[1], repo: match[2] } return { owner: match[1], repo: match[2] }
} }
/**
* Extracts displayable text from assistant response parts.
* Returns null for tool-only or reasoning-only responses (signals summary needed).
* Throws for truly unusable responses (empty, step-start only, etc.).
*/
export function extractResponseText(parts: MessageV2.Part[]): string | null {
// Priority 1: Look for text parts
const textPart = parts.findLast((p) => p.type === "text")
if (textPart) return textPart.text
// Priority 2: Reasoning-only - return null to signal summary needed
const reasoningPart = parts.findLast((p) => p.type === "reasoning")
if (reasoningPart) return null
// Priority 3: Tool-only - return null to signal summary needed
const toolParts = parts.filter((p) => p.type === "tool" && p.state.status === "completed")
if (toolParts.length > 0) return null
// No usable parts - throw with debug info
const partTypes = parts.map((p) => p.type).join(", ") || "none"
throw new Error(`Failed to parse response. Part types found: [${partTypes}]`)
}
export const GithubCommand = cmd({ export const GithubCommand = cmd({
command: "github", command: "github",
describe: "manage GitHub agent", describe: "manage GitHub agent",
@@ -397,27 +431,38 @@ export const GithubRunCommand = cmd({
core.setFailed(`Unsupported event type: ${context.eventName}`) core.setFailed(`Unsupported event type: ${context.eventName}`)
process.exit(1) process.exit(1)
} }
// Determine event category for routing
// USER_EVENTS: have actor, issueId, support reactions/comments
// REPO_EVENTS: no actor/issueId, output to logs/PR only
const isUserEvent = USER_EVENTS.includes(context.eventName as UserEvent)
const isRepoEvent = REPO_EVENTS.includes(context.eventName as RepoEvent)
const isCommentEvent = ["issue_comment", "pull_request_review_comment"].includes(context.eventName) const isCommentEvent = ["issue_comment", "pull_request_review_comment"].includes(context.eventName)
const isIssuesEvent = context.eventName === "issues"
const isScheduleEvent = context.eventName === "schedule" const isScheduleEvent = context.eventName === "schedule"
const isWorkflowDispatchEvent = context.eventName === "workflow_dispatch"
const { providerID, modelID } = normalizeModel() const { providerID, modelID } = normalizeModel()
const runId = normalizeRunId() const runId = normalizeRunId()
const share = normalizeShare() const share = normalizeShare()
const oidcBaseUrl = normalizeOidcBaseUrl() const oidcBaseUrl = normalizeOidcBaseUrl()
const { owner, repo } = context.repo const { owner, repo } = context.repo
// For schedule events, payload has no issue/comment data // For repo events (schedule, workflow_dispatch), payload has no issue/comment data
const payload = context.payload as const payload = context.payload as
| IssueCommentEvent | IssueCommentEvent
| IssuesEvent
| PullRequestReviewCommentEvent | PullRequestReviewCommentEvent
| WorkflowDispatchEvent
| WorkflowRunEvent | WorkflowRunEvent
| PullRequestEvent | PullRequestEvent
const issueEvent = isIssueCommentEvent(payload) ? payload : undefined const issueEvent = isIssueCommentEvent(payload) ? payload : undefined
// workflow_dispatch has an actor (the user who triggered it), schedule does not
const actor = isScheduleEvent ? undefined : context.actor const actor = isScheduleEvent ? undefined : context.actor
const issueId = isScheduleEvent const issueId = isRepoEvent
? undefined ? undefined
: context.eventName === "issue_comment" : context.eventName === "issue_comment" || context.eventName === "issues"
? (payload as IssueCommentEvent).issue.number ? (payload as IssueCommentEvent | IssuesEvent).issue.number
: (payload as PullRequestEvent | PullRequestReviewCommentEvent).pull_request.number : (payload as PullRequestEvent | PullRequestReviewCommentEvent).pull_request.number
const runUrl = `/${owner}/${repo}/actions/runs/${runId}` const runUrl = `/${owner}/${repo}/actions/runs/${runId}`
const shareBaseUrl = isMock ? "https://dev.opencode.ai" : "https://opencode.ai" const shareBaseUrl = isMock ? "https://dev.opencode.ai" : "https://opencode.ai"
@@ -462,8 +507,8 @@ export const GithubRunCommand = cmd({
if (!useGithubToken) { if (!useGithubToken) {
await configureGit(appToken) await configureGit(appToken)
} }
// Skip permission check for schedule events (no actor to check) // Skip permission check and reactions for repo events (no actor to check, no issue to react to)
if (!isScheduleEvent) { if (isUserEvent) {
await assertPermissions() await assertPermissions()
await addReaction(commentType) await addReaction(commentType)
} }
@@ -480,25 +525,30 @@ export const GithubRunCommand = cmd({
})() })()
console.log("opencode session", session.id) console.log("opencode session", session.id)
// Handle 4 cases // Handle event types:
// 1. Schedule (no issue/PR context) // REPO_EVENTS (schedule, workflow_dispatch): no issue/PR context, output to logs/PR only
// 2. Issue // USER_EVENTS on PR (pull_request, pull_request_review_comment, issue_comment on PR): work on PR branch
// 3. Local PR // USER_EVENTS on Issue (issue_comment on issue, issues): create new branch, may create PR
// 4. Fork PR if (isRepoEvent) {
if (isScheduleEvent) { // Repo event - no issue/PR context, output goes to logs
// Schedule event - no issue/PR context, output goes to logs if (isWorkflowDispatchEvent && actor) {
const branch = await checkoutNewBranch("schedule") console.log(`Triggered by: ${actor}`)
}
const branchPrefix = isWorkflowDispatchEvent ? "dispatch" : "schedule"
const branch = await checkoutNewBranch(branchPrefix)
const head = (await $`git rev-parse HEAD`).stdout.toString().trim() const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
const response = await chat(userPrompt, promptFiles) const response = await chat(userPrompt, promptFiles)
const { dirty, uncommittedChanges } = await branchIsDirty(head) const { dirty, uncommittedChanges } = await branchIsDirty(head)
if (dirty) { if (dirty) {
const summary = await summarize(response) const summary = await summarize(response)
await pushToNewBranch(summary, branch, uncommittedChanges, true) // workflow_dispatch has an actor for co-author attribution, schedule does not
await pushToNewBranch(summary, branch, uncommittedChanges, isScheduleEvent)
const triggerType = isWorkflowDispatchEvent ? "workflow_dispatch" : "scheduled workflow"
const pr = await createPR( const pr = await createPR(
repoData.data.default_branch, repoData.data.default_branch,
branch, branch,
summary, summary,
`${response}\n\nTriggered by scheduled workflow${footer({ image: true })}`, `${response}\n\nTriggered by ${triggerType}${footer({ image: true })}`,
) )
console.log(`Created PR #${pr}`) console.log(`Created PR #${pr}`)
} else { } else {
@@ -573,7 +623,7 @@ export const GithubRunCommand = cmd({
} else if (e instanceof Error) { } else if (e instanceof Error) {
msg = e.message msg = e.message
} }
if (!isScheduleEvent) { if (isUserEvent) {
await createComment(`${msg}${footer()}`) await createComment(`${msg}${footer()}`)
await removeReaction(commentType) await removeReaction(commentType)
} }
@@ -628,9 +678,15 @@ export const GithubRunCommand = cmd({
} }
function isIssueCommentEvent( function isIssueCommentEvent(
event: IssueCommentEvent | PullRequestReviewCommentEvent | WorkflowRunEvent | PullRequestEvent, event:
| IssueCommentEvent
| IssuesEvent
| PullRequestReviewCommentEvent
| WorkflowDispatchEvent
| WorkflowRunEvent
| PullRequestEvent,
): event is IssueCommentEvent { ): event is IssueCommentEvent {
return "issue" in event return "issue" in event && "comment" in event
} }
function getReviewCommentContext() { function getReviewCommentContext() {
@@ -652,10 +708,11 @@ export const GithubRunCommand = cmd({
async function getUserPrompt() { async function getUserPrompt() {
const customPrompt = process.env["PROMPT"] const customPrompt = process.env["PROMPT"]
// For schedule events, PROMPT is required since there's no comment to extract from // For repo events and issues events, PROMPT is required since there's no comment to extract from
if (isScheduleEvent) { if (isRepoEvent || isIssuesEvent) {
if (!customPrompt) { if (!customPrompt) {
throw new Error("PROMPT input is required for scheduled events") const eventType = isRepoEvent ? "scheduled and workflow_dispatch" : "issues"
throw new Error(`PROMPT input is required for ${eventType} events`)
} }
return { userPrompt: customPrompt, promptFiles: [] } return { userPrompt: customPrompt, promptFiles: [] }
} }
@@ -856,10 +913,41 @@ export const GithubRunCommand = cmd({
) )
} }
const match = result.parts.findLast((p) => p.type === "text") const text = extractResponseText(result.parts)
if (!match) throw new Error("Failed to parse the text response") if (text) return text
return match.text // No text part (tool-only or reasoning-only) - ask agent to summarize
console.log("Requesting summary from agent...")
const summary = await SessionPrompt.prompt({
sessionID: session.id,
messageID: Identifier.ascending("message"),
model: {
providerID,
modelID,
},
tools: { "*": false }, // Disable all tools to force text response
parts: [
{
id: Identifier.ascending("part"),
type: "text",
text: "Summarize the actions (tool calls & reasoning) you did for the user in 1-2 sentences.",
},
],
})
if (summary.info.role === "assistant" && summary.info.error) {
console.error(summary.info)
throw new Error(
`${summary.info.error.name}: ${"message" in summary.info.error ? summary.info.error.message : ""}`,
)
}
const summaryText = extractResponseText(summary.parts)
if (!summaryText) {
throw new Error("Failed to get summary from agent")
}
return summaryText
} }
async function getOidcToken() { async function getOidcToken() {
@@ -923,7 +1011,7 @@ export const GithubRunCommand = cmd({
await $`git config --local ${config} "${gitConfig}"` await $`git config --local ${config} "${gitConfig}"`
} }
async function checkoutNewBranch(type: "issue" | "schedule") { async function checkoutNewBranch(type: "issue" | "schedule" | "dispatch") {
console.log("Checking out new branch...") console.log("Checking out new branch...")
const branch = generateBranchName(type) const branch = generateBranchName(type)
await $`git checkout -b ${branch}` await $`git checkout -b ${branch}`
@@ -952,16 +1040,16 @@ export const GithubRunCommand = cmd({
await $`git checkout -b ${localBranch} fork/${remoteBranch}` await $`git checkout -b ${localBranch} fork/${remoteBranch}`
} }
function generateBranchName(type: "issue" | "pr" | "schedule") { function generateBranchName(type: "issue" | "pr" | "schedule" | "dispatch") {
const timestamp = new Date() const timestamp = new Date()
.toISOString() .toISOString()
.replace(/[:-]/g, "") .replace(/[:-]/g, "")
.replace(/\.\d{3}Z/, "") .replace(/\.\d{3}Z/, "")
.split("T") .split("T")
.join("") .join("")
if (type === "schedule") { if (type === "schedule" || type === "dispatch") {
const hex = crypto.randomUUID().slice(0, 6) const hex = crypto.randomUUID().slice(0, 6)
return `opencode/scheduled-${hex}-${timestamp}` return `opencode/${type}-${hex}-${timestamp}`
} }
return `opencode/${type}${issueId}-${timestamp}` return `opencode/${type}${issueId}-${timestamp}`
} }
+4 -19
View File
@@ -1,29 +1,14 @@
import { Server } from "../../server/server" import { Server } from "../../server/server"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
export const ServeCommand = cmd({ export const ServeCommand = cmd({
command: "serve", command: "serve",
builder: (yargs) => builder: (yargs) => withNetworkOptions(yargs),
yargs
.option("port", {
alias: ["p"],
type: "number",
describe: "port to listen on",
default: 0,
})
.option("hostname", {
type: "string",
describe: "hostname to listen on",
default: "127.0.0.1",
}),
describe: "starts a headless opencode server", describe: "starts a headless opencode server",
handler: async (args) => { handler: async (args) => {
const hostname = args.hostname const opts = await resolveNetworkOptions(args)
const port = args.port const server = Server.listen(opts)
const server = Server.listen({
port,
hostname,
})
console.log(`opencode server listening on http://${server.hostname}:${server.port}`) console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
await new Promise(() => {}) await new Promise(() => {})
await server.stop() await server.stop()
+14 -5
View File
@@ -82,12 +82,21 @@ async function getAllSessions(): Promise<Session.Info[]> {
return sessions return sessions
} }
async function aggregateSessionStats(days?: number, projectFilter?: string): Promise<SessionStats> { export async function aggregateSessionStats(days?: number, projectFilter?: string): Promise<SessionStats> {
const sessions = await getAllSessions() const sessions = await getAllSessions()
const DAYS_IN_SECOND = 24 * 60 * 60 * 1000 const MS_IN_DAY = 24 * 60 * 60 * 1000
const cutoffTime = days ? Date.now() - days * DAYS_IN_SECOND : 0
let filteredSessions = days ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions const cutoffTime = (() => {
if (days === undefined) return 0
if (days === 0) {
const now = new Date()
now.setHours(0, 0, 0, 0)
return now.getTime()
}
return Date.now() - days * MS_IN_DAY
})()
let filteredSessions = cutoffTime > 0 ? sessions.filter((session) => session.time.updated >= cutoffTime) : sessions
if (projectFilter !== undefined) { if (projectFilter !== undefined) {
if (projectFilter === "") { if (projectFilter === "") {
@@ -198,7 +207,7 @@ async function aggregateSessionStats(days?: number, projectFilter?: string): Pro
} }
} }
const actualDays = Math.max(1, Math.ceil((latestTime - earliestTime) / DAYS_IN_SECOND)) const actualDays = Math.max(1, Math.ceil((latestTime - earliestTime) / MS_IN_DAY))
stats.dateRange = { stats.dateRange = {
earliest: earliestTime, earliest: earliestTime,
latest: latestTime, latest: latestTime,
+10 -1
View File
@@ -372,6 +372,15 @@ function App() {
local.agent.move(1) local.agent.move(1)
}, },
}, },
{
title: "Variant cycle",
value: "variant.cycle",
keybind: "variant_cycle",
category: "Agent",
onSelect: () => {
local.model.variant.cycle()
},
},
{ {
title: "Agent cycle reverse", title: "Agent cycle reverse",
value: "agent.cycle.reverse", value: "agent.cycle.reverse",
@@ -539,7 +548,7 @@ function App() {
sdk.event.on(SessionApi.Event.Error.type, (evt) => { sdk.event.on(SessionApi.Event.Error.type, (evt) => {
const error = evt.properties.error const error = evt.properties.error
const message = (() => { const message = (() => {
if (!error) return "An error occured" if (!error) return "An error occurred"
if (typeof error === "object") { if (typeof error === "object") {
const data = error.data const data = error.data
@@ -37,11 +37,9 @@ export function DialogModel(props: { providerID?: string }) {
const recents = local.model.recent() const recents = local.model.recent()
const recentList = showExtra() const recentList = showExtra()
? recents ? recents.filter(
.filter( (item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
(item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID), )
)
.slice(0, 5)
: [] : []
const favoriteOptions = favorites.flatMap((item) => { const favoriteOptions = favorites.flatMap((item) => {
@@ -152,7 +150,7 @@ export function DialogModel(props: { providerID?: string }) {
(item) => item.providerID === value.providerID && item.modelID === value.modelID, (item) => item.providerID === value.providerID && item.modelID === value.modelID,
) )
if (inFavorites) return false if (inFavorites) return false
const inRecents = recents.some( const inRecents = recentList.some(
(item) => item.providerID === value.providerID && item.modelID === value.modelID, (item) => item.providerID === value.providerID && item.modelID === value.modelID,
) )
if (inRecents) return false if (inRecents) return false
@@ -182,7 +180,10 @@ export function DialogModel(props: { providerID?: string }) {
// Apply fuzzy filtering to each section separately, maintaining section order // Apply fuzzy filtering to each section separately, maintaining section order
if (q) { if (q) {
const filteredFavorites = fuzzysort.go(q, favoriteOptions, { keys: ["title"] }).map((x) => x.obj) const filteredFavorites = fuzzysort.go(q, favoriteOptions, { keys: ["title"] }).map((x) => x.obj)
const filteredRecents = fuzzysort.go(q, recentOptions, { keys: ["title"] }).map((x) => x.obj) const filteredRecents = fuzzysort
.go(q, recentOptions, { keys: ["title"] })
.map((x) => x.obj)
.slice(0, 5)
const filteredProviders = fuzzysort.go(q, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj) const filteredProviders = fuzzysort.go(q, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj)
const filteredPopular = fuzzysort.go(q, popularProviders, { keys: ["title"] }).map((x) => x.obj) const filteredPopular = fuzzysort.go(q, popularProviders, { keys: ["title"] }).map((x) => x.obj)
return [...filteredFavorites, ...filteredRecents, ...filteredProviders, ...filteredPopular] return [...filteredFavorites, ...filteredRecents, ...filteredProviders, ...filteredPopular]
@@ -5,6 +5,7 @@ import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog" import { useDialog } from "@tui/ui/dialog"
import { useSDK } from "../context/sdk" import { useSDK } from "../context/sdk"
import { DialogPrompt } from "../ui/dialog-prompt" import { DialogPrompt } from "../ui/dialog-prompt"
import { Link } from "../ui/link"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { TextAttributes } from "@opentui/core" import { TextAttributes } from "@opentui/core"
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2" import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2"
@@ -128,7 +129,7 @@ function AutoMethod(props: AutoMethodProps) {
<text fg={theme.textMuted}>esc</text> <text fg={theme.textMuted}>esc</text>
</box> </box>
<box gap={1}> <box gap={1}>
<text fg={theme.primary}>{props.authorization.url}</text> <Link href={props.authorization.url} fg={theme.primary} />
<text fg={theme.textMuted}>{props.authorization.instructions}</text> <text fg={theme.textMuted}>{props.authorization.instructions}</text>
</box> </box>
<text fg={theme.textMuted}>Waiting for authorization...</text> <text fg={theme.textMuted}>Waiting for authorization...</text>
@@ -170,7 +171,7 @@ function CodeMethod(props: CodeMethodProps) {
description={() => ( description={() => (
<box gap={1}> <box gap={1}>
<text fg={theme.textMuted}>{props.authorization.instructions}</text> <text fg={theme.textMuted}>{props.authorization.instructions}</text>
<text fg={theme.primary}>{props.authorization.url}</text> <Link href={props.authorization.url} fg={theme.primary} />
<Show when={error()}> <Show when={error()}>
<text fg={theme.error}>Invalid code</text> <text fg={theme.error}>Invalid code</text>
</Show> </Show>
@@ -2,12 +2,13 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select" import { DialogSelect } from "@tui/ui/dialog-select"
import { useRoute } from "@tui/context/route" import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { createEffect, createMemo, createSignal, onMount } from "solid-js" import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { Keybind } from "@/util/keybind" import { Keybind } from "@/util/keybind"
import { useTheme } from "../context/theme" import { useTheme } from "../context/theme"
import { useSDK } from "../context/sdk" import { useSDK } from "../context/sdk"
import { DialogSessionRename } from "./dialog-session-rename" import { DialogSessionRename } from "./dialog-session-rename"
import { useKV } from "../context/kv"
import "opentui-spinner/solid" import "opentui-spinner/solid"
export function DialogSessionList() { export function DialogSessionList() {
@@ -16,6 +17,7 @@ export function DialogSessionList() {
const { theme } = useTheme() const { theme } = useTheme()
const route = useRoute() const route = useRoute()
const sdk = useSDK() const sdk = useSDK()
const kv = useKV()
const [toDelete, setToDelete] = createSignal<string>() const [toDelete, setToDelete] = createSignal<string>()
@@ -45,7 +47,11 @@ export function DialogSessionList() {
value: x.id, value: x.id,
category, category,
footer: Locale.time(x.time.updated), footer: Locale.time(x.time.updated),
gutter: isWorking ? <spinner frames={spinnerFrames} interval={80} color={theme.primary} /> : undefined, gutter: isWorking ? (
<Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}>
<spinner frames={spinnerFrames} interval={80} color={theme.primary} />
</Show>
) : undefined,
} }
}) })
.slice(0, 150) .slice(0, 150)
@@ -12,6 +12,38 @@ import { useTerminalDimensions } from "@opentui/solid"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import type { PromptInfo } from "./history" import type { PromptInfo } from "./history"
function removeLineRange(input: string) {
const hashIndex = input.lastIndexOf("#")
return hashIndex !== -1 ? input.substring(0, hashIndex) : input
}
function extractLineRange(input: string) {
const hashIndex = input.lastIndexOf("#")
if (hashIndex === -1) {
return { baseQuery: input }
}
const baseName = input.substring(0, hashIndex)
const linePart = input.substring(hashIndex + 1)
const lineMatch = linePart.match(/^(\d+)(?:-(\d*))?$/)
if (!lineMatch) {
return { baseQuery: baseName }
}
const startLine = Number(lineMatch[1])
const endLine = lineMatch[2] && startLine < Number(lineMatch[2]) ? Number(lineMatch[2]) : undefined
return {
lineRange: {
baseName,
startLine,
endLine,
},
baseQuery: baseName,
}
}
export type AutocompleteRef = { export type AutocompleteRef = {
onInput: (value: string) => void onInput: (value: string) => void
onKeyDown: (e: KeyEvent) => void onKeyDown: (e: KeyEvent) => void
@@ -142,9 +174,11 @@ export function Autocomplete(props: {
async (query) => { async (query) => {
if (!store.visible || store.visible === "/") return [] if (!store.visible || store.visible === "/") return []
const { lineRange, baseQuery } = extractLineRange(query ?? "")
// Get files from SDK // Get files from SDK
const result = await sdk.client.find.files({ const result = await sdk.client.find.files({
query: query ?? "", query: baseQuery,
}) })
const options: AutocompleteOption[] = [] const options: AutocompleteOption[] = []
@@ -153,15 +187,27 @@ export function Autocomplete(props: {
if (!result.error && result.data) { if (!result.error && result.data) {
const width = props.anchor().width - 4 const width = props.anchor().width - 4
options.push( options.push(
...result.data.map( ...result.data.map((item): AutocompleteOption => {
(item): AutocompleteOption => ({ let url = `file://${process.cwd()}/${item}`
display: Locale.truncateMiddle(item, width), let filename = item
if (lineRange && !item.endsWith("/")) {
filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
const urlObj = new URL(url)
urlObj.searchParams.set("start", String(lineRange.startLine))
if (lineRange.endLine !== undefined) {
urlObj.searchParams.set("end", String(lineRange.endLine))
}
url = urlObj.toString()
}
return {
display: Locale.truncateMiddle(filename, width),
onSelect: () => { onSelect: () => {
insertPart(item, { insertPart(filename, {
type: "file", type: "file",
mime: "text/plain", mime: "text/plain",
filename: item, filename,
url: `file://${process.cwd()}/${item}`, url,
source: { source: {
type: "file", type: "file",
text: { text: {
@@ -173,8 +219,8 @@ export function Autocomplete(props: {
}, },
}) })
}, },
}), }
), }),
) )
} }
@@ -383,8 +429,8 @@ export function Autocomplete(props: {
return prev return prev
} }
const result = fuzzysort.go(currentFilter, mixed, { const result = fuzzysort.go(removeLineRange(currentFilter), mixed, {
keys: [(obj) => obj.display.trimEnd(), "description", (obj) => obj.aliases?.join(" ") ?? ""], keys: [(obj) => removeLineRange(obj.display.trimEnd()), "description", (obj) => obj.aliases?.join(" ") ?? ""],
limit: 10, limit: 10,
scoreFn: (objResults) => { scoreFn: (objResults) => {
const displayResult = objResults[0] const displayResult = objResults[0]
@@ -29,6 +29,7 @@ import { useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderConnect } from "../dialog-provider" import { DialogProvider as DialogProviderConnect } from "../dialog-provider"
import { DialogAlert } from "../../ui/dialog-alert" import { DialogAlert } from "../../ui/dialog-alert"
import { useToast } from "../../ui/toast" import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
export type PromptProps = { export type PromptProps = {
sessionID?: string sessionID?: string
@@ -124,6 +125,7 @@ export function Prompt(props: PromptProps) {
const command = useCommandDialog() const command = useCommandDialog()
const renderer = useRenderer() const renderer = useRenderer()
const { theme, syntax } = useTheme() const { theme, syntax } = useTheme()
const kv = useKV()
function promptModelWarning() { function promptModelWarning() {
toast.show({ toast.show({
@@ -165,6 +167,13 @@ export function Prompt(props: PromptProps) {
if (!props.disabled) input.cursorColor = theme.text if (!props.disabled) input.cursorColor = theme.text
}) })
const lastUserMessage = createMemo(() => {
if (!props.sessionID) return undefined
const messages = sync.data.message[props.sessionID]
if (!messages) return undefined
return messages.findLast((m) => m.role === "user")
})
const [store, setStore] = createStore<{ const [store, setStore] = createStore<{
prompt: PromptInfo prompt: PromptInfo
mode: "normal" | "shell" mode: "normal" | "shell"
@@ -182,6 +191,23 @@ export function Prompt(props: PromptProps) {
interrupt: 0, interrupt: 0,
}) })
// Initialize agent/model/variant from last user message when session changes
let syncedSessionID: string | undefined
createEffect(() => {
const sessionID = props.sessionID
const msg = lastUserMessage()
if (sessionID !== syncedSessionID) {
if (!sessionID || !msg) return
syncedSessionID = sessionID
if (msg.agent) local.agent.set(msg.agent)
if (msg.model) local.model.set(msg.model)
if (msg.variant) local.model.variant.set(msg.variant)
}
})
command.register(() => { command.register(() => {
return [ return [
{ {
@@ -560,6 +586,7 @@ export function Prompt(props: PromptProps) {
// Capture mode before it gets reset // Capture mode before it gets reset
const currentMode = store.mode const currentMode = store.mode
const variant = local.model.variant.current()
if (store.mode === "shell") { if (store.mode === "shell") {
sdk.client.session.shell({ sdk.client.session.shell({
@@ -588,6 +615,7 @@ export function Prompt(props: PromptProps) {
agent: local.agent.current().name, agent: local.agent.current().name,
model: `${selectedModel.providerID}/${selectedModel.modelID}`, model: `${selectedModel.providerID}/${selectedModel.modelID}`,
messageID, messageID,
variant,
}) })
} else { } else {
sdk.client.session.prompt({ sdk.client.session.prompt({
@@ -596,6 +624,7 @@ export function Prompt(props: PromptProps) {
messageID, messageID,
agent: local.agent.current().name, agent: local.agent.current().name,
model: selectedModel, model: selectedModel,
variant,
parts: [ parts: [
{ {
id: Identifier.ascending("part"), id: Identifier.ascending("part"),
@@ -716,6 +745,13 @@ export function Prompt(props: PromptProps) {
return local.agent.color(local.agent.current().name) return local.agent.color(local.agent.current().name)
}) })
const showVariant = createMemo(() => {
const variants = local.model.variant.list()
if (variants.length === 0) return false
const current = local.model.variant.current()
return !!current
})
const spinnerDef = createMemo(() => { const spinnerDef = createMemo(() => {
const color = local.agent.color(local.agent.current().name) const color = local.agent.color(local.agent.current().name)
return { return {
@@ -956,6 +992,12 @@ export function Prompt(props: PromptProps) {
{local.model.parsed().model} {local.model.parsed().model}
</text> </text>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text> <text fg={theme.textMuted}>{local.model.parsed().provider}</text>
<Show when={showVariant()}>
<text fg={theme.textMuted}>·</text>
<text>
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
</text>
</Show>
</box> </box>
</Show> </Show>
</box> </box>
@@ -996,8 +1038,11 @@ export function Prompt(props: PromptProps) {
justifyContent={status().type === "retry" ? "space-between" : "flex-start"} justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
> >
<box flexShrink={0} flexDirection="row" gap={1}> <box flexShrink={0} flexDirection="row" gap={1}>
{/* @ts-ignore // SpinnerOptions doesn't support marginLeft */} <box marginLeft={1}>
<spinner marginLeft={1} color={spinnerDef().color} frames={spinnerDef().frames} interval={40} /> <Show when={kv.get("animations_enabled", true)} fallback={<text fg={theme.textMuted}>[]</text>}>
<spinner color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
</Show>
</box>
<box flexDirection="row" gap={1} flexShrink={0}> <box flexDirection="row" gap={1} flexShrink={0}>
{(() => { {(() => {
const retry = createMemo(() => { const retry = createMemo(() => {
@@ -33,24 +33,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} }
} }
// Automatically update model when agent changes
createEffect(() => {
const value = agent.current()
if (value.model) {
if (isModelValid(value.model))
model.set({
providerID: value.model.providerID,
modelID: value.model.modelID,
})
else
toast.show({
variant: "warning",
message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
duration: 3000,
})
}
})
const agent = iife(() => { const agent = iife(() => {
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
const [agentStore, setAgentStore] = createStore<{ const [agentStore, setAgentStore] = createStore<{
@@ -120,11 +102,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
providerID: string providerID: string
modelID: string modelID: string
}[] }[]
variant: Record<string, string | undefined>
}>({ }>({
ready: false, ready: false,
model: {}, model: {},
recent: [], recent: [],
favorite: [], favorite: [],
variant: {},
}) })
const file = Bun.file(path.join(Global.Path.state, "model.json")) const file = Bun.file(path.join(Global.Path.state, "model.json"))
@@ -135,6 +119,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
JSON.stringify({ JSON.stringify({
recent: modelStore.recent, recent: modelStore.recent,
favorite: modelStore.favorite, favorite: modelStore.favorite,
variant: modelStore.variant,
}), }),
) )
} }
@@ -144,6 +129,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
.then((x) => { .then((x) => {
if (Array.isArray(x.recent)) setModelStore("recent", x.recent) if (Array.isArray(x.recent)) setModelStore("recent", x.recent)
if (Array.isArray(x.favorite)) setModelStore("favorite", x.favorite) if (Array.isArray(x.favorite)) setModelStore("favorite", x.favorite)
if (typeof x.variant === "object" && x.variant !== null) setModelStore("variant", x.variant)
}) })
.catch(() => {}) .catch(() => {})
.finally(() => { .finally(() => {
@@ -218,6 +204,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return { return {
provider: "Connect a provider", provider: "Connect a provider",
model: "No provider selected", model: "No provider selected",
reasoning: false,
} }
} }
const provider = sync.data.provider.find((x) => x.id === value.providerID) const provider = sync.data.provider.find((x) => x.id === value.providerID)
@@ -225,6 +212,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return { return {
provider: provider?.name ?? value.providerID, provider: provider?.name ?? value.providerID,
model: info?.name ?? value.modelID, model: info?.name ?? value.modelID,
reasoning: info?.capabilities?.reasoning ?? false,
} }
}), }),
cycle(direction: 1 | -1) { cycle(direction: 1 | -1) {
@@ -267,7 +255,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setModelStore("model", agent.current().name, { ...next }) setModelStore("model", agent.current().name, { ...next })
const uniq = uniqueBy([next, ...modelStore.recent], (x) => x.providerID + x.modelID) const uniq = uniqueBy([next, ...modelStore.recent], (x) => x.providerID + x.modelID)
if (uniq.length > 10) uniq.pop() if (uniq.length > 10) uniq.pop()
setModelStore("recent", uniq) setModelStore(
"recent",
uniq.map((x) => ({ providerID: x.providerID, modelID: x.modelID })),
)
save() save()
}, },
set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) { set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
@@ -284,7 +275,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (options?.recent) { if (options?.recent) {
const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID) const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID)
if (uniq.length > 10) uniq.pop() if (uniq.length > 10) uniq.pop()
setModelStore("recent", uniq) setModelStore(
"recent",
uniq.map((x) => ({ providerID: x.providerID, modelID: x.modelID })),
)
save() save()
} }
}) })
@@ -305,10 +299,53 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const next = exists const next = exists
? modelStore.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID) ? modelStore.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID)
: [model, ...modelStore.favorite] : [model, ...modelStore.favorite]
setModelStore("favorite", next) setModelStore(
"favorite",
next.map((x) => ({ providerID: x.providerID, modelID: x.modelID })),
)
save() save()
}) })
}, },
variant: {
current() {
const m = currentModel()
if (!m) return undefined
const key = `${m.providerID}/${m.modelID}`
return modelStore.variant[key]
},
list() {
const m = currentModel()
if (!m) return []
const provider = sync.data.provider.find((x) => x.id === m.providerID)
const info = provider?.models[m.modelID]
if (!info?.variants) return []
return Object.entries(info.variants)
.filter(([_, v]) => !v.disabled)
.map(([name]) => name)
},
set(value: string | undefined) {
const m = currentModel()
if (!m) return
const key = `${m.providerID}/${m.modelID}`
setModelStore("variant", key, value)
save()
},
cycle() {
const variants = this.list()
if (variants.length === 0) return
const current = this.current()
if (!current) {
this.set(variants[0])
return
}
const index = variants.indexOf(current)
if (index === -1 || index === variants.length - 1) {
this.set(undefined)
return
}
this.set(variants[index + 1])
},
},
} }
}) })
@@ -329,6 +366,24 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}, },
} }
// Automatically update model when agent changes
createEffect(() => {
const value = agent.current()
if (value.model) {
if (isModelValid(value.model))
model.set({
providerID: value.model.providerID,
modelID: value.model.modelID,
})
else
toast.show({
variant: "warning",
message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
duration: 3000,
})
}
})
const result = { const result = {
model, model,
agent, agent,
@@ -52,7 +52,6 @@ import { DialogMessage } from "./dialog-message"
import type { PromptInfo } from "../../component/prompt/history" import type { PromptInfo } from "../../component/prompt/history"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { DialogConfirm } from "@tui/ui/dialog-confirm" import { DialogConfirm } from "@tui/ui/dialog-confirm"
import { DialogPrompt } from "@tui/ui/dialog-prompt"
import { DialogTimeline } from "./dialog-timeline" import { DialogTimeline } from "./dialog-timeline"
import { DialogForkFromTimeline } from "./dialog-fork-from-timeline" import { DialogForkFromTimeline } from "./dialog-fork-from-timeline"
import { DialogSessionRename } from "../../component/dialog-session-rename" import { DialogSessionRename } from "../../component/dialog-session-rename"
@@ -67,7 +66,7 @@ import stripAnsi from "strip-ansi"
import { Footer } from "./footer.tsx" import { Footer } from "./footer.tsx"
import { usePromptRef } from "../../context/prompt" import { usePromptRef } from "../../context/prompt"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { DialogSubagent } from "./dialog-subagent.tsx" import { DialogExportOptions } from "../../ui/dialog-export-options"
addDefaultParsers(parsers.parsers) addDefaultParsers(parsers.parsers)
@@ -128,6 +127,7 @@ export function Session() {
const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false)) const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false))
const [userMessageMarkdown, setUserMessageMarkdown] = createSignal(kv.get("user_message_markdown", true)) const [userMessageMarkdown, setUserMessageMarkdown] = createSignal(kv.get("user_message_markdown", true))
const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word") const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
const [animationsEnabled, setAnimationsEnabled] = createSignal(kv.get("animations_enabled", true))
const wide = createMemo(() => dimensions().width > 120) const wide = createMemo(() => dimensions().width > 120)
const sidebarVisible = createMemo(() => { const sidebarVisible = createMemo(() => {
@@ -584,6 +584,19 @@ export function Session() {
dialog.clear() dialog.clear()
}, },
}, },
{
title: animationsEnabled() ? "Disable animations" : "Enable animations",
value: "session.toggle.animations",
category: "Session",
onSelect: (dialog) => {
setAnimationsEnabled((prev) => {
const next = !prev
kv.set("animations_enabled", next)
return next
})
dialog.clear()
},
},
{ {
title: "Page up", title: "Page up",
value: "session.page.up", value: "session.page.up",
@@ -770,8 +783,22 @@ export function Session() {
for (const part of parts) { for (const part of parts) {
if (part.type === "text" && !part.synthetic) { if (part.type === "text" && !part.synthetic) {
transcript += `${part.text}\n\n` transcript += `${part.text}\n\n`
} else if (part.type === "reasoning") {
if (showThinking()) {
transcript += `_Thinking:_\n\n${part.text}\n\n`
}
} else if (part.type === "tool") { } else if (part.type === "tool") {
transcript += `\`\`\`\nTool: ${part.tool}\n\`\`\`\n\n` transcript += `\`\`\`\nTool: ${part.tool}\n`
if (showDetails() && part.state.input) {
transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\``
}
if (showDetails() && part.state.status === "completed" && part.state.output) {
transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\``
}
if (showDetails() && part.state.status === "error" && part.state.error) {
transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\``
}
transcript += `\n\`\`\`\n\n`
} }
} }
@@ -798,6 +825,14 @@ export function Session() {
const sessionData = session() const sessionData = session()
const sessionMessages = messages() const sessionMessages = messages()
const defaultFilename = `session-${sessionData.id.slice(0, 8)}.md`
const options = await DialogExportOptions.show(dialog, defaultFilename, showThinking(), showDetails())
if (options === null) return
const { filename: customFilename, thinking: includeThinking, toolDetails: includeToolDetails } = options
let transcript = `# ${sessionData.title}\n\n` let transcript = `# ${sessionData.title}\n\n`
transcript += `**Session ID:** ${sessionData.id}\n` transcript += `**Session ID:** ${sessionData.id}\n`
transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n` transcript += `**Created:** ${new Date(sessionData.time.created).toLocaleString()}\n`
@@ -812,22 +847,28 @@ export function Session() {
for (const part of parts) { for (const part of parts) {
if (part.type === "text" && !part.synthetic) { if (part.type === "text" && !part.synthetic) {
transcript += `${part.text}\n\n` transcript += `${part.text}\n\n`
} else if (part.type === "reasoning") {
if (includeThinking) {
transcript += `_Thinking:_\n\n${part.text}\n\n`
}
} else if (part.type === "tool") { } else if (part.type === "tool") {
transcript += `\`\`\`\nTool: ${part.tool}\n\`\`\`\n\n` transcript += `\`\`\`\nTool: ${part.tool}\n`
if (includeToolDetails && part.state.input) {
transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\``
}
if (includeToolDetails && part.state.status === "completed" && part.state.output) {
transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\``
}
if (includeToolDetails && part.state.status === "error" && part.state.error) {
transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\``
}
transcript += `\n\`\`\`\n\n`
} }
} }
transcript += `---\n\n` transcript += `---\n\n`
} }
// Prompt for optional filename
const customFilename = await DialogPrompt.show(dialog, "Export filename", {
value: `session-${sessionData.id.slice(0, 8)}.md`,
})
// Cancel if user pressed escape
if (customFilename === null) return
// Save to file in current working directory // Save to file in current working directory
const exportDir = process.cwd() const exportDir = process.cwd()
const filename = customFilename.trim() const filename = customFilename.trim()
+7 -19
View File
@@ -3,31 +3,19 @@ import { Instance } from "@/project/instance"
import path from "path" import path from "path"
import { Server } from "@/server/server" import { Server } from "@/server/server"
import { upgrade } from "@/cli/upgrade" import { upgrade } from "@/cli/upgrade"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
export const TuiSpawnCommand = cmd({ export const TuiSpawnCommand = cmd({
command: "spawn [project]", command: "spawn [project]",
builder: (yargs) => builder: (yargs) =>
yargs withNetworkOptions(yargs).positional("project", {
.positional("project", { type: "string",
type: "string", describe: "path to start opencode in",
describe: "path to start opencode in", }),
})
.option("port", {
type: "number",
describe: "port to listen on",
default: 0,
})
.option("hostname", {
type: "string",
describe: "hostname to listen on",
default: "127.0.0.1",
}),
handler: async (args) => { handler: async (args) => {
upgrade() upgrade()
const server = Server.listen({ const opts = await resolveNetworkOptions(args)
port: args.port, const server = Server.listen(opts)
hostname: "127.0.0.1",
})
const bin = process.execPath const bin = process.execPath
const cmd = [] const cmd = []
let cwd = process.cwd() let cwd = process.cwd()
+4 -16
View File
@@ -6,6 +6,7 @@ import path from "path"
import { UI } from "@/cli/ui" import { UI } from "@/cli/ui"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { withNetworkOptions, resolveNetworkOptions } from "@/cli/network"
declare global { declare global {
const OPENCODE_WORKER_PATH: string const OPENCODE_WORKER_PATH: string
@@ -15,7 +16,7 @@ export const TuiThreadCommand = cmd({
command: "$0 [project]", command: "$0 [project]",
describe: "start opencode tui", describe: "start opencode tui",
builder: (yargs) => builder: (yargs) =>
yargs withNetworkOptions(yargs)
.positional("project", { .positional("project", {
type: "string", type: "string",
describe: "path to start opencode in", describe: "path to start opencode in",
@@ -36,23 +37,12 @@ export const TuiThreadCommand = cmd({
describe: "session id to continue", describe: "session id to continue",
}) })
.option("prompt", { .option("prompt", {
alias: ["p"],
type: "string", type: "string",
describe: "prompt to use", describe: "prompt to use",
}) })
.option("agent", { .option("agent", {
type: "string", type: "string",
describe: "agent to use", describe: "agent to use",
})
.option("port", {
type: "number",
describe: "port to listen on",
default: 0,
})
.option("hostname", {
type: "string",
describe: "hostname to listen on",
default: "127.0.0.1",
}), }),
handler: async (args) => { handler: async (args) => {
// Resolve relative paths against PWD to preserve behavior when using --cwd flag // Resolve relative paths against PWD to preserve behavior when using --cwd flag
@@ -87,10 +77,8 @@ export const TuiThreadCommand = cmd({
process.on("unhandledRejection", (e) => { process.on("unhandledRejection", (e) => {
Log.Default.error(e) Log.Default.error(e)
}) })
const server = await client.call("server", { const opts = await resolveNetworkOptions(args)
port: args.port, const server = await client.call("server", opts)
hostname: args.hostname,
})
const prompt = await iife(async () => { const prompt = await iife(async () => {
const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined const piped = !process.stdin.isTTY ? await Bun.stdin.text() : undefined
if (!args.prompt) return piped if (!args.prompt) return piped
@@ -0,0 +1,148 @@
import { TextareaRenderable, TextAttributes } from "@opentui/core"
import { useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "./dialog"
import { createStore } from "solid-js/store"
import { onMount, Show, type JSX } from "solid-js"
import { useKeyboard } from "@opentui/solid"
export type DialogExportOptionsProps = {
defaultFilename: string
defaultThinking: boolean
defaultToolDetails: boolean
onConfirm?: (options: { filename: string; thinking: boolean; toolDetails: boolean }) => void
onCancel?: () => void
}
export function DialogExportOptions(props: DialogExportOptionsProps) {
const dialog = useDialog()
const { theme } = useTheme()
let textarea: TextareaRenderable
const [store, setStore] = createStore({
thinking: props.defaultThinking,
toolDetails: props.defaultToolDetails,
active: "filename" as "filename" | "thinking" | "toolDetails",
})
useKeyboard((evt) => {
if (evt.name === "return") {
props.onConfirm?.({
filename: textarea.plainText,
thinking: store.thinking,
toolDetails: store.toolDetails,
})
}
if (evt.name === "tab") {
const order: Array<"filename" | "thinking" | "toolDetails"> = ["filename", "thinking", "toolDetails"]
const currentIndex = order.indexOf(store.active)
const nextIndex = (currentIndex + 1) % order.length
setStore("active", order[nextIndex])
evt.preventDefault()
}
if (evt.name === "space") {
if (store.active === "thinking") setStore("thinking", !store.thinking)
if (store.active === "toolDetails") setStore("toolDetails", !store.toolDetails)
evt.preventDefault()
}
})
onMount(() => {
dialog.setSize("medium")
setTimeout(() => {
textarea.focus()
}, 1)
textarea.gotoLineEnd()
})
return (
<box paddingLeft={2} paddingRight={2} gap={1}>
<box flexDirection="row" justifyContent="space-between">
<text attributes={TextAttributes.BOLD} fg={theme.text}>
Export Options
</text>
<text fg={theme.textMuted}>esc</text>
</box>
<box gap={1}>
<box>
<text fg={theme.text}>Filename:</text>
</box>
<textarea
onSubmit={() => {
props.onConfirm?.({
filename: textarea.plainText,
thinking: store.thinking,
toolDetails: store.toolDetails,
})
}}
height={3}
keyBindings={[{ name: "return", action: "submit" }]}
ref={(val: TextareaRenderable) => (textarea = val)}
initialValue={props.defaultFilename}
placeholder="Enter filename"
textColor={theme.text}
focusedTextColor={theme.text}
cursorColor={theme.text}
/>
</box>
<box flexDirection="column">
<box
flexDirection="row"
gap={2}
paddingLeft={1}
backgroundColor={store.active === "thinking" ? theme.backgroundElement : undefined}
onMouseUp={() => setStore("active", "thinking")}
>
<text fg={store.active === "thinking" ? theme.primary : theme.textMuted}>
{store.thinking ? "[x]" : "[ ]"}
</text>
<text fg={store.active === "thinking" ? theme.primary : theme.text}>Include thinking</text>
</box>
<box
flexDirection="row"
gap={2}
paddingLeft={1}
backgroundColor={store.active === "toolDetails" ? theme.backgroundElement : undefined}
onMouseUp={() => setStore("active", "toolDetails")}
>
<text fg={store.active === "toolDetails" ? theme.primary : theme.textMuted}>
{store.toolDetails ? "[x]" : "[ ]"}
</text>
<text fg={store.active === "toolDetails" ? theme.primary : theme.text}>Include tool details</text>
</box>
</box>
<Show when={store.active !== "filename"}>
<text fg={theme.textMuted} paddingBottom={1}>
Press <span style={{ fg: theme.text }}>space</span> to toggle, <span style={{ fg: theme.text }}>return</span>{" "}
to confirm
</text>
</Show>
<Show when={store.active === "filename"}>
<text fg={theme.textMuted} paddingBottom={1}>
Press <span style={{ fg: theme.text }}>return</span> to confirm, <span style={{ fg: theme.text }}>tab</span>{" "}
for options
</text>
</Show>
</box>
)
}
DialogExportOptions.show = (
dialog: DialogContext,
defaultFilename: string,
defaultThinking: boolean,
defaultToolDetails: boolean,
) => {
return new Promise<{ filename: string; thinking: boolean; toolDetails: boolean } | null>((resolve) => {
dialog.replace(
() => (
<DialogExportOptions
defaultFilename={defaultFilename}
defaultThinking={defaultThinking}
defaultToolDetails={defaultToolDetails}
onConfirm={(options) => resolve(options)}
onCancel={() => resolve(null)}
/>
),
() => resolve(null),
)
})
}

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