Compare commits

...

106 Commits

Author SHA1 Message Date
opencode effa7b45cf release: v1.0.202 2025-12-26 02:11:47 +00:00
Adam b307075063 chore: brain icon 2025-12-25 20:06:41 -06:00
Adam aaf9a5d434 fix(desktop): user message display 2025-12-25 19:45:20 -06:00
Adam e9c2f1f3f3 fix(desktop): padding 2025-12-25 19:22:16 -06:00
Adam 7469cba7cf fix(desktop): move session context to top-right 2025-12-25 19:21:04 -06:00
Adam 5420702f69 fix(desktop): missing keybinds in tooltips 2025-12-25 19:07:42 -06:00
Adam 583751ecae fix(desktop): markdown rendering perf 2025-12-25 19:07:42 -06:00
GitHub Action d0a1b5ef96 chore: generate 2025-12-26 01:03:22 +00:00
Adam 42f2bc7199 fix(desktop): can't collapse project with active session 2025-12-25 19:02:43 -06:00
Adam 603dae562a chore(ui): radio group primitive 2025-12-25 18:46:57 -06:00
Adam 650bd76370 feat(desktop): better indicator that session is busy 2025-12-25 14:31:10 -06:00
opencode 8aa3520683 release: v1.0.201 2025-12-25 14:07:19 +00:00
Adam 5b5b8c57d9 fix(desktop): so many prompt input fixes, merry christmas 2025-12-25 08:04:42 -06:00
GitHub Action f057b22e20 ignore: update download stats 2025-12-25 2025-12-25 12:04:54 +00:00
GitHub Action 388d40e41f chore: generate 2025-12-25 05:59:27 +00:00
Dax Raad f397c92ddf remove list tool 2025-12-25 00:58:47 -05:00
opencode 6f9bea4e1f release: v1.0.200 2025-12-25 04:39:32 +00:00
Adam 5c49b4cbfc fix(desktop): scroll jank in session turn and review 2025-12-24 22:36:45 -06:00
GitHub Action b746e831e2 chore: generate 2025-12-25 04:17:39 +00:00
Adam 2178deef91 fix(desktop): override agent model 2025-12-24 22:16:58 -06:00
Adam b1d2fb5319 fix(desktop): reconcile session diff updates 2025-12-24 22:16:44 -06:00
opencode 2284a4e6df release: v1.0.199 2025-12-25 02:40:19 +00:00
Adam ad852d9186 chore: toast on file load error 2025-12-24 20:37:12 -06:00
Adam 8a9b4245b4 chore: cleanup dead code 2025-12-24 20:30:52 -06:00
Adam 76ac1ccb6b chore: show version on error page 2025-12-24 20:25:36 -06:00
Adam e71bc8c0b0 fix(desktop): show server connection failure 2025-12-24 20:11:37 -06:00
Ahmed Mansour a5301e2ab7 fix: correct Content-Type headers for static assets on app.opencode.ai (#6113) 2025-12-24 20:00:22 -06:00
Connor Adams 8eac72341f docs: update skills to use canonical ~/.config/opencode location (#6132) 2025-12-24 18:18:33 -06:00
Robb Tolliver bd139b4bd6 docs: Corrected the number of built-in subagents in documentation (#6133) 2025-12-24 18:02:35 -06:00
GitHub Action 508578bf17 chore: generate 2025-12-24 19:21:10 +00:00
Dax Raad 607d8aafb7 tui: disable tips display in home route 2025-12-24 14:20:29 -05:00
Dax Raad 5843eca7d6 CI 2025-12-24 18:16:46 +00:00
opencode ff3b68bd36 release: v1.0.198 2025-12-24 18:16:46 +00:00
Dax Raad 474b6fd3d1 ci 2025-12-24 13:12:29 -05:00
GitHub Action 6145b197f3 chore: generate 2025-12-24 18:08:42 +00:00
Dax Raad 918eff9233 ci 2025-12-24 13:07:56 -05:00
opencode 987e444828 release: v1.0.197 2025-12-24 17:47:07 +00:00
Dax Raad 99633cb299 Revert "feat: better styling for small screens (short and/or not wide) (#5968)"
This reverts commit ac371d2987.
2025-12-24 12:38:10 -05:00
GitHub Action f822331eb8 chore: generate 2025-12-24 17:07:43 +00:00
Patrick Schiel 0f053769db docs: add infos about server debugging (#6085) 2025-12-24 11:07:12 -06:00
opencode ceeaf494c4 release: v1.0.196 2025-12-24 16:40:16 +00:00
Adam 126d887e57 fix(desktop): last text part streaming 2025-12-24 10:35:52 -06:00
Adam e5cfc24d6b fix(desktop): render perf 2025-12-24 10:26:49 -06:00
Jay V 7f8d659737 docs: edits 2025-12-24 11:23:51 -05:00
Jay V 4b061653f2 docs: add comprehensive CLI command documentation for agent, mcp, session, stats, and web commands 2025-12-24 11:12:09 -05:00
Jay V eeed89f985 docs: make MCP server documentation more scannable and add Sentry example 2025-12-24 10:49:48 -05:00
Adam 8ab533b616 chore: cleanup 2025-12-24 09:07:31 -06:00
Adam 09a399d8d6 fix(desktop): summary flicker 2025-12-24 09:07:31 -06:00
Adam b75575884a feat(desktop): show read tool args 2025-12-24 09:07:31 -06:00
GitHub Action 5688c9fd61 chore: generate 2025-12-24 14:56:15 +00:00
Adam 08a075df61 fix(desktop): better session navigation, hide child sessions 2025-12-24 08:55:32 -06:00
opencode a2e8737114 release: v1.0.195 2025-12-24 14:50:40 +00:00
Adam 776a394b02 chore: cleanup 2025-12-24 08:46:11 -06:00
GitHub Action 5788b33fdf chore: generate 2025-12-24 14:38:25 +00:00
Adam 0f270c3da4 refactor(ui): rewrite createAutoScroll with robust event tracking to fix sticky behavior 2025-12-24 08:37:49 -06:00
opencode 376019e347 release: v1.0.194 2025-12-24 12:20:02 +00:00
Adam 44b773a6f6 chore: cleanup 2025-12-24 06:16:17 -06:00
Adam df97774f7f fix(desktop): session sort when multiple active 2025-12-24 06:16:17 -06:00
Adam eeff62a912 fix(share): page title should be session title 2025-12-24 06:16:17 -06:00
GitHub Action 3fc6c42f5f ignore: update download stats 2025-12-24 2025-12-24 12:04:46 +00:00
Adam 967d8238be fix(desktop): exclude deprecated models 2025-12-24 06:01:27 -06:00
Adam bff7518a24 fix(desktop): auto-scroll 2025-12-24 05:57:48 -06:00
Adam 8eab677094 fix: don't disable text selection 2025-12-24 05:57:48 -06:00
Github Action db57e7023a Update Nix flake.lock and hashes 2025-12-24 11:56:43 +00:00
Adam ede4e467db deps: update marked and marked-shiki 2025-12-24 05:55:28 -06:00
Adam aa1c560e5e fix(desktop): hang on backtracing-prone regex 2025-12-24 05:49:35 -06:00
Adam 3aca9e5fa5 fix(desktop): conditionally show review pane toggle 2025-12-24 05:22:25 -06:00
Ryan Vogel 9e96d83164 fix: remove SVG favicon to improve SEO (#5755) 2025-12-24 05:17:13 -06:00
Aiden Cline 4275907df6 docs: tweak lsp.mdx 2025-12-23 22:38:17 -06:00
opencode-agent[bot] 6097d6af86 docs: experimental LSP tool (#5943)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-12-23 22:37:49 -06:00
opencode-agent[bot] 09d2febe27 docs: skill tool/perm + parent keybind (#6001)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-12-23 22:25:55 -06:00
xiantang 2c5c1ecb5e docs: add Neovim to the list of editors (#6081) 2025-12-23 22:17:34 -06:00
Aiden Cline 99e2112807 tweak: retry err 2025-12-23 22:10:28 -06:00
GitHub Action 4b6575999d chore: generate 2025-12-24 01:37:35 +00:00
Frank 1a9ee3080c zen: sync 2025-12-23 20:36:55 -05:00
Abdelkader Boudih f4d61be8bd feat(mcp): handle tools/list_changed notifications (#5913)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-12-23 19:36:37 -06:00
Aiden Cline 8b40e38cd7 test: add test for retry 2025-12-23 19:34:40 -06:00
Aiden Cline 7396d495ee chore: regen sdk 2025-12-23 19:34:38 -06:00
GitHub Action f9b5ce180a chore: generate 2025-12-24 01:21:10 +00:00
Aiden Cline 12ee9d51c3 make 'The socket connection was closed unexpectedly' errors retryable 2025-12-23 19:20:31 -06:00
Rohan Mukherjee 2730e0c9cd chore: update AGENTS.md to ~150 lines (#5955) 2025-12-23 19:04:44 -06:00
David Hill d6c81d6e14 style: update current todo style (#6077) 2025-12-23 18:57:02 -06:00
rari404 e8ac0b663b feat(tui): console copy-to-clipboard via opentui (#5658)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-12-23 18:46:01 -06:00
OpeOginni 2806f240ea fix: resize textarea when pasting prompt less than 150 chars (#6070) 2025-12-23 18:11:48 -06:00
Matt Silverlock 9898fbe8ef providers: add Cloudflare AI Gateway (#5174) 2025-12-23 17:31:58 -06:00
Frank 1bd8e61719 ci: adam is not a full stack engineer 2025-12-23 17:03:00 -05:00
Adam b6c07cb1b8 fix: remove desktop dup 2025-12-23 15:31:18 -06:00
Viktor Nagy 83f23817ce Update gitlab.mdx to use the 2.x component version (#6062) 2025-12-23 14:56:46 -06:00
Github Action 23b1d7c755 Update Nix flake.lock and hashes 2025-12-23 20:42:17 +00:00
Aiden Cline ef033db9c2 Revert "Add animated braille spinner to terminal title when agent is running (#5984)"
This reverts commit 59b87f60f7.
2025-12-23 14:40:55 -06:00
Aiden Cline e30d5d8e34 tweak: update import & pr commands to use new share link ur 2025-12-23 14:04:33 -06:00
GitHub Action 698cfb57a1 chore: generate 2025-12-23 19:48:40 +00:00
Jon Redeker 27e72f2652 Add opencode-shell-strategy plugin to ecosystem (#5995) 2025-12-23 13:48:08 -06:00
ja 10eed6ee7e feat(install): add standard CLI flags (--help, --version, --no-modify-path) (#5885) 2025-12-23 13:47:32 -06:00
David Hill 59b87f60f7 Add animated braille spinner to terminal title when agent is running (#5984)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Github Action <action@github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-12-23 13:38:10 -06:00
GitHub Action d10089a0bf chore: generate 2025-12-23 19:29:05 +00:00
David Hill ae7286c031 "Did you know?" start screen tips (#5982)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-12-23 13:28:33 -06:00
Daniel Gray 52048c327d fix: favorites and recents stay visible when filtering models (#6053) 2025-12-23 12:55:47 -06:00
Matt Silverlock 4e1a9b6216 docs: add MCP OAuth debugging section (#6047) 2025-12-23 12:46:59 -06:00
Aiden Cline 1995be3599 ci: update zed sync script 2025-12-23 12:45:34 -06:00
ja 86b9b7b15a fix(tui): prevent keybinds from executing when dialog is open (#6017) 2025-12-23 12:37:28 -06:00
Frank a90f2b9723 ci: fix 2025-12-23 12:58:28 -05:00
GitHub Action c73a17f8af chore: generate 2025-12-23 15:58:33 +00:00
Rhys Sullivan 48898fda07 [feat]: prompt stashing (#6021) 2025-12-23 09:58:00 -06:00
GitHub Action c573732ddb chore: generate 2025-12-23 15:39:47 +00:00
Daniel Polito ab2a6c45a3 Fix Github Pull Request Event (#6037) 2025-12-23 09:39:14 -06:00
108 changed files with 2658 additions and 1166 deletions
+2
View File
@@ -6,6 +6,8 @@ You are an expert technical documentation writer
You are not verbose
Use a relaxed and friendly tone
The title of the page should be a word or a 2-3 word phrase
The description should be one short line, should not start with "The", should
+4 -4
View File
@@ -53,12 +53,12 @@ your debugger via that URL. Other methods can result in breakpoints being mapped
Caveats:
- `*.tsx` files won't have their breakpoints correctly mapped. This seems due to Bun currently not supporting source maps on code transformed
via `BunPlugin`s (currently necessary due to our dependency on `@opentui/solid`). Currently, the best you can do in terms of debugging `*.tsx`
files is writing a `debugger;` statement. Debugging facilities like stepping won't work, but at least you will be informed if a specific code
is triggered.
- If you want to run the OpenCode TUI and have breakpoints triggered in the server code, you might need to run `bun dev spawn` instead of
the usual `bun dev`. This is because `bun dev` runs the server in a worker thread and breakpoints might not work there.
- If `spawn` does not work for you, you can debug the server separately:
- Debug server: `bun run --inspect=ws://localhost:6499/ ./src/index.ts serve --port 4096`,
then attach TUI with `opencode attach http://localhost:4096`
- Debug TUI: `bun run --inspect=ws://localhost:6499/ --conditions=browser ./src/index.ts`
Other tips and tricks:
+2
View File
@@ -179,3 +179,5 @@
| 2025-12-21 | 1,242,675 (+19,675) | 1,158,909 (+12,651) | 2,401,584 (+32,326) |
| 2025-12-22 | 1,262,522 (+19,847) | 1,169,121 (+10,212) | 2,431,643 (+30,059) |
| 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) |
| 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) |
+24 -24
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.0.193",
"version": "1.0.202",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -51,8 +51,8 @@
"fuzzysort": "catalog:",
"ghostty-web": "0.3.0",
"luxon": "catalog:",
"marked": "16.2.0",
"marked-shiki": "1.2.1",
"marked": "catalog:",
"marked-shiki": "catalog:",
"remeda": "catalog:",
"shiki": "catalog:",
"solid-js": "catalog:",
@@ -77,7 +77,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.0.193",
"version": "1.0.202",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -105,7 +105,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.0.193",
"version": "1.0.202",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -132,7 +132,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.0.193",
"version": "1.0.202",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -156,7 +156,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.0.193",
"version": "1.0.202",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -180,7 +180,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.0.193",
"version": "1.0.202",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@solid-primitives/storage": "catalog:",
@@ -207,7 +207,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.0.193",
"version": "1.0.202",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -236,7 +236,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.0.193",
"version": "1.0.202",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -252,7 +252,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.0.193",
"version": "1.0.202",
"bin": {
"opencode": "./bin/opencode",
},
@@ -346,7 +346,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.0.193",
"version": "1.0.202",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -366,7 +366,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.0.193",
"version": "1.0.202",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -377,7 +377,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.0.193",
"version": "1.0.202",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -390,7 +390,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.0.193",
"version": "1.0.202",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -403,8 +403,8 @@
"@typescript/native-preview": "catalog:",
"fuzzysort": "catalog:",
"luxon": "catalog:",
"marked": "16.2.0",
"marked-shiki": "1.2.1",
"marked": "catalog:",
"marked-shiki": "catalog:",
"remeda": "catalog:",
"shiki": "catalog:",
"solid-js": "catalog:",
@@ -425,7 +425,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.0.193",
"version": "1.0.202",
"dependencies": {
"zod": "catalog:",
},
@@ -436,7 +436,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.0.193",
"version": "1.0.202",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -451,8 +451,8 @@
"js-base64": "3.7.7",
"lang-map": "0.4.0",
"luxon": "catalog:",
"marked": "15.0.12",
"marked-shiki": "1.2.1",
"marked": "catalog:",
"marked-shiki": "catalog:",
"rehype-autolink-headings": "7.1.0",
"remeda": "catalog:",
"shiki": "catalog:",
@@ -502,6 +502,8 @@
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"luxon": "3.6.1",
"marked": "17.0.1",
"marked-shiki": "1.2.1",
"remeda": "2.26.0",
"shiki": "3.20.0",
"solid-js": "1.9.10",
@@ -2865,7 +2867,7 @@
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"marked": ["marked@16.2.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-LbbTuye+0dWRz2TS9KJ7wsnD4KAtpj0MVkWc90XvBa6AslXsT0hTBVH5k32pcSyHH1fst9XEFJunXHktVy0zlg=="],
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
"marked-shiki": ["marked-shiki@1.2.1", "", { "peerDependencies": { "marked": ">=7.0.0", "shiki": ">=1.0.0" } }, "sha512-yHxYQhPY5oYaIRnROn98foKhuClark7M373/VpLxiy5TrDu9Jd/LsMwo8w+U91Up4oDb9IXFrP0N1MFRz8W/DQ=="],
@@ -4113,8 +4115,6 @@
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/types": "3.4.2" } }, "sha512-I5baLVi/ynLEOZoWSAMlACHNnG+yw5HDmse0oe+GW6U1u+ULdEB3UHiVWaHoJSSONV7tlcVxuaMy74sREDkSvg=="],
"@opencode-ai/web/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
"@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
"@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.9", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.1" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.8" }, "optionalPeers": ["solid-js"] }, "sha512-pCnxWrciluXCeli/dj5PIEHgbNzim3evtTn12snjqqg8QZWJNMjH1AWIp4iG/tbVjqQ72aBEymMSagvmgxubXw=="],
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1766314097,
"narHash": "sha256-laJftWbghBehazn/zxVJ8NdENVgjccsWAdAqKXhErrM=",
"lastModified": 1766532406,
"narHash": "sha256-acLU/ag9VEoKkzOD202QASX25nG1eArXg5A0mHjKgxM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "306ea70f9eb0fb4e040f8540e2deab32ed7e2055",
"rev": "8142186f001295e5a3239f485c8a49bf2de2695a",
"type": "github"
},
"original": {
+1
View File
@@ -103,6 +103,7 @@ const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS3"),
new sst.Secret("ZEN_MODELS4"),
new sst.Secret("ZEN_MODELS5"),
new sst.Secret("ZEN_MODELS6"),
]
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
-6
View File
@@ -1,6 +0,0 @@
import { domain } from "./stage"
new sst.cloudflare.StaticSite("Desktop", {
domain: "desktop." + domain,
path: "packages/app/public",
})
+79 -35
View File
@@ -7,7 +7,51 @@ RED='\033[0;31m'
ORANGE='\033[38;5;214m'
NC='\033[0m' # No Color
usage() {
cat <<EOF
OpenCode Installer
Usage: install.sh [options]
Options:
-h, --help Display this help message
-v, --version <version> Install a specific version (e.g., 1.0.180)
--no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.)
Examples:
curl -fsSL https://opencode.ai/install | bash
curl -fsSL https://opencode.ai/install | bash -s -- --version 1.0.180
EOF
}
requested_version=${VERSION:-}
no_modify_path=false
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
-v|--version)
if [[ -n "${2:-}" ]]; then
requested_version="$2"
shift 2
else
echo -e "${RED}Error: --version requires a version argument${NC}"
exit 1
fi
;;
--no-modify-path)
no_modify_path=true
shift
;;
*)
echo -e "${ORANGE}Warning: Unknown option '$1'${NC}" >&2
shift
;;
esac
done
raw_os=$(uname -s)
os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
@@ -304,42 +348,42 @@ case $current_shell in
;;
esac
config_file=""
for file in $config_files; do
if [[ -f $file ]]; then
config_file=$file
break
if [[ "$no_modify_path" != "true" ]]; then
config_file=""
for file in $config_files; do
if [[ -f $file ]]; then
config_file=$file
break
fi
done
if [[ -z $config_file ]]; then
print_message warning "No config file found for $current_shell. You may need to manually add to PATH:"
print_message info " export PATH=$INSTALL_DIR:\$PATH"
elif [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
case $current_shell in
fish)
add_to_path "$config_file" "fish_add_path $INSTALL_DIR"
;;
zsh)
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
;;
bash)
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
;;
ash)
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
;;
sh)
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
;;
*)
export PATH=$INSTALL_DIR:$PATH
print_message warning "Manually add the directory to $config_file (or similar):"
print_message info " export PATH=$INSTALL_DIR:\$PATH"
;;
esac
fi
done
if [[ -z $config_file ]]; then
print_message error "No config file found for $current_shell. Checked files: ${config_files[@]}"
exit 1
fi
if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
case $current_shell in
fish)
add_to_path "$config_file" "fish_add_path $INSTALL_DIR"
;;
zsh)
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
;;
bash)
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
;;
ash)
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
;;
sh)
add_to_path "$config_file" "export PATH=$INSTALL_DIR:\$PATH"
;;
*)
export PATH=$INSTALL_DIR:$PATH
print_message warning "Manually add the directory to $config_file (or similar):"
print_message info " export PATH=$INSTALL_DIR:\$PATH"
;;
esac
fi
if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
+1 -1
View File
@@ -1,3 +1,3 @@
{
"nodeModules": "sha256-CDOAY2h2AAcSuVqV1uyxDmfzSa/vV8lnXOKDgAC4mgg="
"nodeModules": "sha256-hotsyeWJA6/dP6DvZTN1Ak2RSKcsyvXlXPI/jexBHME="
}
+2
View File
@@ -40,6 +40,8 @@
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
"luxon": "3.6.1",
"marked": "17.0.1",
"marked-shiki": "1.2.1",
"typescript": "5.8.2",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"zod": "4.1.8",
+1 -1
View File
@@ -14,7 +14,7 @@
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
</head>
<body class="antialiased overscroll-none select-none text-12-regular overflow-hidden">
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<script>
;(function () {
const savedTheme = localStorage.getItem("theme") || "oc-1"
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.0.193",
"version": "1.0.202",
"description": "",
"type": "module",
"exports": {
@@ -49,8 +49,8 @@
"fuzzysort": "catalog:",
"ghostty-web": "0.3.0",
"luxon": "catalog:",
"marked": "16.2.0",
"marked-shiki": "1.2.1",
"marked": "catalog:",
"marked-shiki": "catalog:",
"remeda": "catalog:",
"shiki": "catalog:",
"solid-js": "catalog:",
+17
View File
@@ -0,0 +1,17 @@
/assets/*.js
Content-Type: application/javascript
/assets/*.mjs
Content-Type: application/javascript
/assets/*.css
Content-Type: text/css
/*.js
Content-Type: application/javascript
/*.mjs
Content-Type: application/javascript
/*.css
Content-Type: text/css
+5 -5
View File
@@ -2,7 +2,7 @@ import { useLocal, type LocalFile } from "@/context/local"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"
import { For, Match, Switch, type ComponentProps, type ParentProps } from "solid-js"
import { Dynamic } from "solid-js/web"
export default function FileTree(props: {
@@ -57,14 +57,14 @@ export default function FileTree(props: {
"text-text-muted/40": p.node.ignored,
"text-text-muted/80": !p.node.ignored,
// "!text-text": local.file.active()?.path === p.node.path,
"!text-primary": local.file.changed(p.node.path),
// "!text-primary": local.file.changed(p.node.path),
}}
>
{p.node.name}
</span>
<Show when={local.file.changed(p.node.path)}>
<span class="ml-auto mr-1 w-1.5 h-1.5 rounded-full bg-primary/50 shrink-0" />
</Show>
{/* <Show when={local.file.changed(p.node.path)}> */}
{/* <span class="ml-auto mr-1 w-1.5 h-1.5 rounded-full bg-primary/50 shrink-0" /> */}
{/* </Show> */}
</Dynamic>
)
+31 -29
View File
@@ -109,35 +109,37 @@ export function Header(props: {
</Show>
</div>
<div class="flex items-center gap-4">
<Tooltip
class="hidden md:block shrink-0"
value={
<div class="flex items-center gap-2">
<span>Toggle review</span>
<span class="text-icon-base text-12-medium">{command.keybind("review.toggle")}</span>
</div>
}
>
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={layout.review.opened() ? "layout-right-full" : "layout-right"}
class="group-hover/review-toggle:hidden"
/>
<Icon
size="small"
name="layout-right-partial"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
size="small"
name={layout.review.opened() ? "layout-right" : "layout-right-full"}
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</Tooltip>
<Show when={currentSession()?.summary?.files}>
<Tooltip
class="hidden md:block shrink-0"
value={
<div class="flex items-center gap-2">
<span>Toggle review</span>
<span class="text-icon-base text-12-medium">{command.keybind("review.toggle")}</span>
</div>
}
>
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={layout.review.opened() ? "layout-right" : "layout-left"}
size="small"
class="group-hover/review-toggle:hidden"
/>
<Icon
name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
size="small"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
size="small"
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</Tooltip>
</Show>
<Tooltip
class="hidden md:block shrink-0"
value={
+89 -65
View File
@@ -103,7 +103,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
imageAttachments: ImageAttachmentPart[]
mode: "normal" | "shell"
applyingHistory: boolean
userHasEdited: boolean
}>({
popover: null,
historyIndex: -1,
@@ -113,7 +112,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
imageAttachments: [],
mode: "normal",
applyingHistory: false,
userHasEdited: false,
})
const MAX_HISTORY = 100
@@ -150,7 +148,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
const length = position === "start" ? 0 : promptLength(p)
setStore("applyingHistory", true)
setStore("userHasEdited", false)
prompt.set(p, length)
requestAnimationFrame(() => {
editorRef.focus()
@@ -279,11 +276,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
createEffect(() => {
if (isFocused()) {
handleInput()
} else {
setStore("popover", null)
}
if (!isFocused()) setStore("popover", null)
})
const handleFileSelect = (path: string | undefined) => {
@@ -363,7 +356,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
() => prompt.current(),
(currentParts) => {
const domParts = parseFromDOM()
if (isPromptEqual(currentParts, domParts)) return
const normalized = Array.from(editorRef.childNodes).every((node) => {
if (node.nodeType === Node.TEXT_NODE) return true
if (node.nodeType !== Node.ELEMENT_NODE) return false
return (node as HTMLElement).dataset.type === "file"
})
if (normalized && isPromptEqual(currentParts, domParts)) return
const selection = window.getSelection()
let cursorPosition: number | null = null
@@ -395,34 +393,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
)
const parseFromDOM = (): Prompt => {
const newParts: Prompt = []
const parts: Prompt = []
let position = 0
let buffer = ""
const pushText = (content: string) => {
const flushText = () => {
const content = buffer.replace(/\r\n?/g, "\n")
buffer = ""
if (!content) return
newParts.push({ type: "text", content, start: position, end: position + content.length })
parts.push({ type: "text", content, start: position, end: position + content.length })
position += content.length
}
const rangeText = (range: Range) => {
const fragment = range.cloneContents()
const container = document.createElement("div")
container.append(fragment)
return container.innerText
}
const files = Array.from(editorRef.querySelectorAll<HTMLElement>("[data-type=file]"))
let last: HTMLElement | undefined
files.forEach((file) => {
const before = document.createRange()
before.selectNodeContents(editorRef)
if (last) before.setStartAfter(last)
before.setEndBefore(file)
pushText(rangeText(before))
const pushFile = (file: HTMLElement) => {
const content = file.textContent ?? ""
newParts.push({
parts.push({
type: "file",
path: file.dataset.path!,
content,
@@ -430,16 +415,44 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
end: position + content.length,
})
position += content.length
last = file
}
const visit = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
buffer += node.textContent ?? ""
return
}
if (node.nodeType !== Node.ELEMENT_NODE) return
const el = node as HTMLElement
if (el.dataset.type === "file") {
flushText()
pushFile(el)
return
}
if (el.tagName === "BR") {
buffer += "\n"
return
}
for (const child of Array.from(el.childNodes)) {
visit(child)
}
}
const children = Array.from(editorRef.childNodes)
children.forEach((child, index) => {
const isBlock = child.nodeType === Node.ELEMENT_NODE && ["DIV", "P"].includes((child as HTMLElement).tagName)
visit(child)
if (isBlock && index < children.length - 1) {
buffer += "\n"
}
})
const after = document.createRange()
after.selectNodeContents(editorRef)
if (last) after.setStartAfter(last)
pushText(rangeText(after))
flushText()
if (newParts.length === 0) newParts.push(...DEFAULT_PROMPT)
return newParts
if (parts.length === 0) parts.push(...DEFAULT_PROMPT)
return parts
}
const handleInput = () => {
@@ -452,7 +465,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (shouldReset) {
setStore("popover", null)
setStore("userHasEdited", false)
if (store.historyIndex >= 0 && !store.applyingHistory) {
setStore("historyIndex", -1)
setStore("savedPrompt", null)
@@ -487,10 +499,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setStore("savedPrompt", null)
}
if (!store.applyingHistory) {
setStore("userHasEdited", true)
}
prompt.set(rawParts, cursorPosition)
}
@@ -516,27 +524,39 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const gap = document.createTextNode(" ")
const range = selection.getRangeAt(0)
if (atMatch) {
let runningLength = 0
const setEdge = (edge: "start" | "end", offset: number) => {
let remaining = offset
const nodes = Array.from(editorRef.childNodes)
const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null)
let currentNode = walker.nextNode()
while (currentNode) {
const textContent = currentNode.textContent || ""
if (runningLength + textContent.length >= atMatch.index!) {
const localStart = atMatch.index! - runningLength
const localEnd = cursorPosition - runningLength
if (currentNode === range.startContainer || runningLength + textContent.length >= cursorPosition) {
range.setStart(currentNode, localStart)
range.setEnd(currentNode, Math.min(localEnd, textContent.length))
break
}
for (const node of nodes) {
const length = node.textContent?.length ?? 0
const isText = node.nodeType === Node.TEXT_NODE
const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
if (isText && remaining <= length) {
if (edge === "start") range.setStart(node, remaining)
if (edge === "end") range.setEnd(node, remaining)
return
}
runningLength += textContent.length
currentNode = walker.nextNode()
if (isFile && remaining <= length) {
if (edge === "start" && remaining === 0) range.setStartBefore(node)
if (edge === "start" && remaining > 0) range.setStartAfter(node)
if (edge === "end" && remaining === 0) range.setEndBefore(node)
if (edge === "end" && remaining > 0) range.setEndAfter(node)
return
}
remaining -= length
}
}
if (atMatch) {
const start = atMatch.index ?? cursorPosition - atMatch[0].length
setEdge("start", start)
setEdge("end", cursorPosition)
}
range.deleteContents()
range.insertNode(gap)
range.insertNode(pill)
@@ -584,8 +604,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const navigateHistory = (direction: "up" | "down") => {
if (store.userHasEdited) return false
const entries = store.mode === "shell" ? shellHistory.entries : history.entries
const current = store.historyIndex
@@ -692,6 +710,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}
if (event.key === "Enter" && event.shiftKey) {
addPart({ type: "text", content: "\n", start: 0, end: 0 })
event.preventDefault()
return
}
if (event.key === "Enter" && !event.shiftKey) {
handleSubmit(event)
}
@@ -717,7 +740,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
addToHistory(currentPrompt, store.mode)
setStore("historyIndex", -1)
setStore("savedPrompt", null)
setStore("userHasEdited", false)
let existing = info()
if (!existing) {
@@ -967,18 +989,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onInput={handleInput}
onKeyDown={handleKeyDown}
classList={{
"w-full px-5 py-3 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&>[data-type=file]]:text-icon-info-active": true,
"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,
"font-mono!": store.mode === "shell",
}}
/>
<Show when={!prompt.dirty() && store.imageAttachments.length === 0}>
<div class="absolute top-0 inset-x-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
<div class="absolute top-0 inset-x-0 px-5 py-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
{store.mode === "shell"
? "Enter shell command..."
: `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
</div>
</Show>
<div class="absolute top-4.5 right-4">
<SessionContextUsage />
</div>
</div>
<div class="relative p-3 flex items-center justify-between">
<div class="flex items-center justify-start gap-1">
@@ -1035,7 +1060,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Tooltip>
</Match>
</Switch>
<SessionContextUsage />
</div>
<div class="flex items-center gap-1 absolute right-2 bottom-2">
<input
@@ -34,28 +34,27 @@ export function SessionContextUsage() {
<Show when={context?.()}>
{(ctx) => (
<Tooltip
openDelay={300}
value={
<div class="flex flex-col gap-1 p-2">
<div class="flex justify-between gap-4">
<span class="text-text-weaker">Tokens</span>
<span class="text-text-strong">{ctx().tokens}</span>
<div class="flex flex-col gap-1">
<div class="flex gap-3">
<span class="opacity-70 text-right flex-1">Tokens</span>
<span class="text-left flex-1">{ctx().tokens}</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-text-weaker">Usage</span>
<span class="text-text-strong">{ctx().percentage ?? 0}%</span>
<div class="flex gap-3">
<span class="opacity-70 text-right flex-1">Usage</span>
<span class="text-left flex-1">{ctx().percentage ?? 0}%</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-text-weaker">Cost</span>
<span class="text-text-strong">{cost()}</span>
<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>
}
placement="top"
>
<div class="flex items-center gap-1">
<span class="text-12-medium text-text-weak">{`${ctx().percentage ?? 0}%`}</span>
<div class="flex items-center gap-1.5">
<ProgressCircle size={16} strokeWidth={2} percentage={ctx().percentage ?? 0} />
{/* <span class="text-12-medium text-text-weak">{`${ctx().percentage ?? 0}%`}</span> */}
</div>
</Tooltip>
)}
+30 -14
View File
@@ -5,8 +5,6 @@ import {
type Part,
type Config,
type Path,
type File,
type FileNode,
type Project,
type FileDiff,
type Todo,
@@ -50,8 +48,6 @@ type State = {
part: {
[messageID: string]: Part[]
}
node: FileNode[]
changes: File[]
}
function createGlobalSync() {
@@ -92,8 +88,6 @@ function createGlobalSync() {
limit: 5,
message: {},
part: {},
node: [],
changes: [],
})
children[directory] = createStore(globalStore.children[directory])
bootstrapInstance(directory)
@@ -136,15 +130,25 @@ function createGlobalSync() {
})
const load = {
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)),
provider: () =>
sdk.provider.list().then((x) => {
const data = x.data!
setStore("provider", {
...data,
all: data.all.map((provider) => ({
...provider,
models: Object.fromEntries(
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
),
})),
})
}),
path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
command: () => sdk.command.list().then((x) => setStore("command", x.data ?? [])),
session: () => loadSessions(directory),
status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)),
node: () => sdk.file.list({ path: "/" }).then((x) => setStore("node", x.data!)),
}
await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
.then(() => setStore("ready", true))
@@ -211,13 +215,13 @@ function createGlobalSync() {
break
}
case "session.diff":
setStore("session_diff", event.properties.sessionID, event.properties.diff)
setStore("session_diff", event.properties.sessionID, reconcile(event.properties.diff, { key: "file" }))
break
case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
setStore("todo", event.properties.sessionID, reconcile(event.properties.todos))
break
case "session.status": {
setStore("session_status", event.properties.sessionID, event.properties.status)
setStore("session_status", event.properties.sessionID, reconcile(event.properties.status))
break
}
case "message.updated": {
@@ -295,7 +299,10 @@ function createGlobalSync() {
})
async function bootstrap() {
const health = await globalSDK.client.global.health().then((x) => x.data)
const health = await globalSDK.client.global
.health()
.then((x) => x.data)
.catch(() => undefined)
if (!health?.healthy) {
setGlobalStore(
"error",
@@ -320,7 +327,16 @@ function createGlobalSync() {
),
retry(() =>
globalSDK.client.provider.list().then((x) => {
setGlobalStore("provider", x.data ?? {})
const data = x.data!
setGlobalStore("provider", {
...data,
all: data.all.map((provider) => ({
...provider,
models: Object.fromEntries(
Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
),
})),
})
}),
),
retry(() =>
+37 -45
View File
@@ -1,5 +1,5 @@
import { createStore, produce, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo } from "solid-js"
import { batch, createMemo } from "solid-js"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "@opencode-ai/ui/context"
@@ -9,6 +9,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { useProviders } from "@/hooks/use-providers"
import { DateTime } from "luxon"
import { persisted } from "@/utils/persist"
import { showToast } from "@opencode-ai/ui/toast"
export type LocalFile = FileNode &
Partial<{
@@ -61,24 +62,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
}
// Automatically update model when agent changes
createEffect(() => {
const value = agent.current()
if (value.model) {
if (isModelValid(value.model))
model.set({
providerID: value.model.providerID,
modelID: value.model.modelID,
})
// else
// toast.show({
// type: "warning",
// message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
// duration: 3000,
// })
}
})
const agent = (() => {
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
const [store, setStore] = createStore<{
@@ -276,11 +259,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [store, setStore] = createStore<{
node: Record<string, LocalFile>
}>({
node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
node: {}, // Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
})
const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
// const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
// const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
// createEffect((prev: FileStatus[]) => {
// const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path))
@@ -308,16 +291,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
// return sync.data.changes
// }, sync.data.changes)
const changed = (path: string) => {
const node = store.node[path]
if (node?.status) return true
const set = changeset()
if (set.has(path)) return true
for (const p of set) {
if (p.startsWith(path ? path + "/" : "")) return true
}
return false
}
// const changed = (path: string) => {
// const node = store.node[path]
// if (node?.status) return true
// const set = changeset()
// if (set.has(path)) return true
// for (const p of set) {
// if (p.startsWith(path ? path + "/" : "")) return true
// }
// return false
// }
// const resetNode = (path: string) => {
// setStore("node", path, {
@@ -336,17 +319,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const load = async (path: string) => {
const relativePath = relative(path)
await sdk.client.file.read({ path: relativePath }).then((x) => {
if (!store.node[relativePath]) return
setStore(
"node",
relativePath,
produce((draft) => {
draft.loaded = true
draft.content = x.data
}),
)
})
await sdk.client.file
.read({ path: relativePath })
.then((x) => {
if (!store.node[relativePath]) return
setStore(
"node",
relativePath,
produce((draft) => {
draft.loaded = true
draft.content = x.data
}),
)
})
.catch((e) => {
showToast({
variant: "error",
title: "Failed to load file",
description: e.message,
})
})
}
const fetch = async (path: string) => {
@@ -466,8 +458,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setChangeIndex(path: string, index: number | undefined) {
setStore("node", path, "selectedChange", index)
},
changes,
changed,
// changes,
// changed,
children(path: string) {
return Object.values(store.node).filter(
(x) =>
+3
View File
@@ -5,6 +5,9 @@ export type Platform = {
/** Platform discriminator */
platform: "web" | "tauri"
/** App version */
version?: string
/** Open a URL in the default browser */
openLink(url: string): void
+2
View File
@@ -2,6 +2,7 @@
import { render } from "solid-js/web"
import { App } from "@/app"
import { Platform, PlatformProvider } from "@/context/platform"
import pkg from "../package.json"
const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
@@ -12,6 +13,7 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
const platform: Platform = {
platform: "web",
version: pkg.version,
openLink(url: string) {
window.open(url, "_blank")
},
+16 -11
View File
@@ -1,7 +1,7 @@
import { TextField } from "@opencode-ai/ui/text-field"
import { Logo } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button"
import { Component } from "solid-js"
import { Component, Show } from "solid-js"
import { usePlatform } from "@/context/platform"
import { Icon } from "@opencode-ai/ui/icon"
@@ -138,16 +138,21 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
<Button size="large" onClick={platform.restart}>
Restart
</Button>
<div class="flex items-center justify-center gap-1">
Please report this error to the OpenCode team
<button
type="button"
class="flex items-center text-text-interactive-base gap-1"
onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
>
<div>on Discord</div>
<Icon name="discord" class="text-text-interactive-base" />
</button>
<div class="flex flex-col items-center gap-2">
<div class="flex items-center justify-center gap-1">
Please report this error to the OpenCode team
<button
type="button"
class="flex items-center text-text-interactive-base gap-1"
onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
>
<div>on Discord</div>
<Icon name="discord" class="text-text-interactive-base" />
</button>
</div>
<Show when={platform.version}>
<p class="text-xs text-text-weak">Version: {platform.version}</p>
</Show>
</div>
</div>
</div>
+50 -69
View File
@@ -9,6 +9,7 @@ import {
ParentProps,
Show,
Switch,
untrack,
type JSX,
} from "solid-js"
import { DateTime } from "luxon"
@@ -116,42 +117,31 @@ export default function Layout(props: ParentProps) {
}
})
function flattenSessions(sessions: Session[]): Session[] {
const childrenMap = new Map<string, Session[]>()
for (const session of sessions) {
if (session.parentID) {
const children = childrenMap.get(session.parentID) ?? []
children.push(session)
childrenMap.set(session.parentID, children)
}
}
const result: Session[] = []
function visit(session: Session) {
result.push(session)
for (const child of childrenMap.get(session.id) ?? []) {
visit(child)
}
}
for (const session of sessions) {
if (!session.parentID) visit(session)
}
return result
function sortSessions(a: Session, b: Session) {
const now = Date.now()
const oneMinuteAgo = now - 60 * 1000
const aUpdated = a.time.updated ?? a.time.created
const bUpdated = b.time.updated ?? b.time.created
const aRecent = aUpdated > oneMinuteAgo
const bRecent = bUpdated > oneMinuteAgo
if (aRecent && bRecent) return a.id.localeCompare(b.id)
if (aRecent && !bRecent) return -1
if (!aRecent && bRecent) return 1
return bUpdated - aUpdated
}
function scrollToSession(sessionId: string) {
if (!scrollContainerRef) return
const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
if (element) {
element.scrollIntoView({ block: "center", behavior: "smooth" })
element.scrollIntoView({ block: "nearest", behavior: "smooth" })
}
}
function projectSessions(directory: string) {
if (!directory) return []
const sessions = globalSync
.child(directory)[0]
.session.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
return flattenSessions(sessions ?? [])
const sessions = globalSync.child(directory)[0].session.toSorted(sortSessions)
return (sessions ?? []).filter((s) => !s.parentID)
}
const currentSessions = createMemo(() => {
@@ -331,8 +321,11 @@ export default function Layout(props: ParentProps) {
createEffect(() => {
if (!params.dir || !params.id) return
const directory = base64Decode(params.dir)
setStore("lastSession", directory, params.id)
notification.session.markViewed(params.id)
const id = params.id
setStore("lastSession", directory, id)
notification.session.markViewed(id)
untrack(() => layout.projects.expand(directory))
requestAnimationFrame(() => scrollToSession(id))
})
createEffect(() => {
@@ -455,13 +448,9 @@ export default function Layout(props: ParentProps) {
session: Session
slug: string
project: LocalProject
depth?: number
childrenMap: Map<string, Session[]>
mobile?: boolean
}): JSX.Element => {
const notification = useNotification()
const depth = props.depth ?? 0
const children = createMemo(() => props.childrenMap.get(props.session.id) ?? [])
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
const notifications = createMemo(() => notification.session.unseen(props.session.id))
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
@@ -476,7 +465,7 @@ export default function Layout(props: ParentProps) {
data-session-id={props.session.id}
class="group/session relative w-full pr-2 py-1 rounded-md cursor-default transition-colors
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
style={{ "padding-left": `${16 + depth * 12}px` }}
style={{ "padding-left": "16px" }}
>
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
<A
@@ -484,7 +473,12 @@ export default function Layout(props: ParentProps) {
class="flex flex-col min-w-0 text-left w-full focus:outline-none"
>
<div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
<span
classList={{
"text-14-regular text-text-strong overflow-hidden text-ellipsis truncate": true,
"animate-pulse": isWorking(),
}}
>
{props.session.title}
</span>
<div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
@@ -525,23 +519,19 @@ export default function Layout(props: ParentProps) {
</A>
</Tooltip>
<div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
<Tooltip placement={props.mobile ? "bottom" : "right"} value="Archive session">
<Tooltip
placement={props.mobile ? "bottom" : "right"}
value={
<div class="flex items-center gap-2">
<span>Archive session</span>
<span class="text-icon-base text-12-medium">{command.keybind("session.archive")}</span>
</div>
}
>
<IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
</Tooltip>
</div>
</div>
<For each={children()}>
{(child) => (
<SessionItem
session={child}
slug={props.slug}
project={props.project}
depth={depth + 1}
childrenMap={props.childrenMap}
mobile={props.mobile}
/>
)}
</For>
</>
)
}
@@ -552,21 +542,8 @@ export default function Layout(props: ParentProps) {
const slug = createMemo(() => base64Encode(props.project.worktree))
const name = createMemo(() => getFilename(props.project.worktree))
const [store, setProjectStore] = globalSync.child(props.project.worktree)
const sessions = createMemo(() =>
store.session.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)),
)
const sessions = createMemo(() => store.session.toSorted(sortSessions))
const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
const childSessionsByParent = createMemo(() => {
const map = new Map<string, Session[]>()
for (const session of sessions()) {
if (session.parentID) {
const children = map.get(session.parentID) ?? []
children.push(session)
map.set(session.parentID, children)
}
}
return map
})
const hasMoreSessions = createMemo(() => store.session.length >= store.limit)
const loadMoreSessions = async () => {
setProjectStore("limit", (limit) => limit + 5)
@@ -615,7 +592,15 @@ export default function Layout(props: ParentProps) {
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
<Tooltip placement="top" value="New session">
<Tooltip
placement="top"
value={
<div class="flex items-center gap-2">
<span>New session</span>
<span class="text-icon-base text-12-medium">{command.keybind("session.new")}</span>
</div>
}
>
<IconButton as={A} href={`${slug()}/session`} icon="plus-small" variant="ghost" />
</Tooltip>
</div>
@@ -624,13 +609,7 @@ export default function Layout(props: ParentProps) {
<nav class="hidden @[4rem]:flex w-full flex-col gap-1.5">
<For each={rootSessions()}>
{(session) => (
<SessionItem
session={session}
slug={slug()}
project={props.project}
childrenMap={childSessionsByParent()}
mobile={props.mobile}
/>
<SessionItem session={session} slug={slug()} project={props.project} mobile={props.mobile} />
)}
</For>
<Show when={rootSessions().length === 0}>
@@ -752,7 +731,9 @@ export default function Layout(props: ParentProps) {
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={sidebarProps.mobile ? undefined : scrollContainerRef}
ref={(el) => {
if (!sidebarProps.mobile) scrollContainerRef = el
}}
class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
>
<SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
+1 -2
View File
@@ -226,8 +226,7 @@ export default function Page() {
title: "Toggle review",
description: "Show or hide the review panel",
category: "View",
keybind: "mod+b",
slash: "review",
keybind: "mod+shift+r",
onSelect: () => layout.review.toggle(),
},
{
+2
View File
@@ -11,6 +11,7 @@
"jsx": "preserve",
"jsxImportSource": "solid-js",
"allowJs": true,
"resolveJsonModule": true,
"strict": true,
"noEmit": false,
"emitDeclarationOnly": true,
@@ -20,5 +21,6 @@
"@/*": ["./src/*"]
}
},
"include": ["src", "package.json"],
"exclude": ["dist", "ts-dist"]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.0.193",
"version": "1.0.202",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.193",
"version": "1.0.202",
"private": true,
"type": "module",
"dependencies": {
@@ -17,14 +17,16 @@ const value2 = lines.find((line) => line.startsWith("ZEN_MODELS2"))?.split("=")[
const value3 = lines.find((line) => line.startsWith("ZEN_MODELS3"))?.split("=")[1]
const value4 = lines.find((line) => line.startsWith("ZEN_MODELS4"))?.split("=")[1]
const value5 = lines.find((line) => line.startsWith("ZEN_MODELS5"))?.split("=")[1]
const value6 = lines.find((line) => line.startsWith("ZEN_MODELS6"))?.split("=")[1]
if (!value1) throw new Error("ZEN_MODELS1 not found")
if (!value2) throw new Error("ZEN_MODELS2 not found")
if (!value3) throw new Error("ZEN_MODELS3 not found")
if (!value4) throw new Error("ZEN_MODELS4 not found")
if (!value5) throw new Error("ZEN_MODELS5 not found")
if (!value6) throw new Error("ZEN_MODELS6 not found")
// validate value
ZenData.validate(JSON.parse(value1 + value2 + value3 + value4 + value5))
ZenData.validate(JSON.parse(value1 + value2 + value3 + value4 + value5 + value6))
// update the secret
await $`bun sst secret set ZEN_MODELS1 ${value1} --stage ${stage}`
@@ -32,3 +34,4 @@ await $`bun sst secret set ZEN_MODELS2 ${value2} --stage ${stage}`
await $`bun sst secret set ZEN_MODELS3 ${value3} --stage ${stage}`
await $`bun sst secret set ZEN_MODELS4 ${value4} --stage ${stage}`
await $`bun sst secret set ZEN_MODELS5 ${value5} --stage ${stage}`
await $`bun sst secret set ZEN_MODELS6 ${value6} --stage ${stage}`
+4 -2
View File
@@ -17,14 +17,15 @@ const value2 = lines.find((line) => line.startsWith("ZEN_MODELS2"))?.split("=")[
const value3 = lines.find((line) => line.startsWith("ZEN_MODELS3"))?.split("=")[1]
const value4 = lines.find((line) => line.startsWith("ZEN_MODELS4"))?.split("=")[1]
const value5 = lines.find((line) => line.startsWith("ZEN_MODELS5"))?.split("=")[1]
const value6 = lines.find((line) => line.startsWith("ZEN_MODELS6"))?.split("=")[1]
if (!value1) throw new Error("ZEN_MODELS1 not found")
if (!value2) throw new Error("ZEN_MODELS2 not found")
if (!value3) throw new Error("ZEN_MODELS3 not found")
if (!value4) throw new Error("ZEN_MODELS4 not found")
if (!value5) throw new Error("ZEN_MODELS5 not found")
if (!value6) throw new Error("ZEN_MODELS6 not found")
// validate value
ZenData.validate(JSON.parse(value1 + value2 + value3 + value4 + value5))
ZenData.validate(JSON.parse(value1 + value2 + value3 + value4 + value5 + value6))
// update the secret
await $`bun sst secret set ZEN_MODELS1 ${value1}`
@@ -32,3 +33,4 @@ await $`bun sst secret set ZEN_MODELS2 ${value2}`
await $`bun sst secret set ZEN_MODELS3 ${value3}`
await $`bun sst secret set ZEN_MODELS4 ${value4}`
await $`bun sst secret set ZEN_MODELS5 ${value5}`
await $`bun sst secret set ZEN_MODELS6 ${value6}`
@@ -15,16 +15,20 @@ const oldValue2 = lines.find((line) => line.startsWith("ZEN_MODELS2"))?.split("=
const oldValue3 = lines.find((line) => line.startsWith("ZEN_MODELS3"))?.split("=")[1]
const oldValue4 = lines.find((line) => line.startsWith("ZEN_MODELS4"))?.split("=")[1]
const oldValue5 = lines.find((line) => line.startsWith("ZEN_MODELS5"))?.split("=")[1]
const oldValue6 = lines.find((line) => line.startsWith("ZEN_MODELS6"))?.split("=")[1]
if (!oldValue1) throw new Error("ZEN_MODELS1 not found")
if (!oldValue2) throw new Error("ZEN_MODELS2 not found")
if (!oldValue3) throw new Error("ZEN_MODELS3 not found")
if (!oldValue4) throw new Error("ZEN_MODELS4 not found")
if (!oldValue5) throw new Error("ZEN_MODELS5 not found")
if (!oldValue6) throw new Error("ZEN_MODELS6 not found")
// store the prettified json to a temp file
const filename = `models-${Date.now()}.json`
const tempFile = Bun.file(path.join(os.tmpdir(), filename))
await tempFile.write(JSON.stringify(JSON.parse(oldValue1 + oldValue2 + oldValue3 + oldValue4 + oldValue5), null, 2))
await tempFile.write(
JSON.stringify(JSON.parse(oldValue1 + oldValue2 + oldValue3 + oldValue4 + oldValue5 + oldValue6), null, 2),
)
console.log("tempFile", tempFile.name)
// open temp file in vim and read the file on close
@@ -33,15 +37,17 @@ const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
ZenData.validate(JSON.parse(newValue))
// update the secret
const chunk = Math.ceil(newValue.length / 5)
const chunk = Math.ceil(newValue.length / 6)
const newValue1 = newValue.slice(0, chunk)
const newValue2 = newValue.slice(chunk, chunk * 2)
const newValue3 = newValue.slice(chunk * 2, chunk * 3)
const newValue4 = newValue.slice(chunk * 3, chunk * 4)
const newValue5 = newValue.slice(chunk * 4)
const newValue5 = newValue.slice(chunk * 4, chunk * 5)
const newValue6 = newValue.slice(chunk * 5)
await $`bun sst secret set ZEN_MODELS1 ${newValue1}`
await $`bun sst secret set ZEN_MODELS2 ${newValue2}`
await $`bun sst secret set ZEN_MODELS3 ${newValue3}`
await $`bun sst secret set ZEN_MODELS4 ${newValue4}`
await $`bun sst secret set ZEN_MODELS5 ${newValue5}`
await $`bun sst secret set ZEN_MODELS6 ${newValue6}`
+2 -1
View File
@@ -72,7 +72,8 @@ export namespace ZenData {
Resource.ZEN_MODELS2.value +
Resource.ZEN_MODELS3.value +
Resource.ZEN_MODELS4.value +
Resource.ZEN_MODELS5.value,
Resource.ZEN_MODELS5.value +
Resource.ZEN_MODELS6.value,
)
return ModelsSchema.parse(json)
})
+8 -4
View File
@@ -42,10 +42,6 @@ declare module "sst" {
"type": "sst.sst.Linkable"
"username": string
}
"Desktop": {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"EMAILOCTOPUS_API_KEY": {
"type": "sst.sst.Secret"
"value": string
@@ -98,6 +94,10 @@ declare module "sst" {
"type": "sst.cloudflare.Astro"
"url": string
}
"WebApp": {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string
@@ -118,6 +118,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS6": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.193",
"version": "1.0.202",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+8 -4
View File
@@ -42,10 +42,6 @@ declare module "sst" {
"type": "sst.sst.Linkable"
"username": string
}
"Desktop": {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"EMAILOCTOPUS_API_KEY": {
"type": "sst.sst.Secret"
"value": string
@@ -98,6 +94,10 @@ declare module "sst" {
"type": "sst.cloudflare.Astro"
"url": string
}
"WebApp": {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string
@@ -118,6 +118,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS6": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.193",
"version": "1.0.202",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+8 -4
View File
@@ -42,10 +42,6 @@ declare module "sst" {
"type": "sst.sst.Linkable"
"username": string
}
"Desktop": {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"EMAILOCTOPUS_API_KEY": {
"type": "sst.sst.Secret"
"value": string
@@ -98,6 +94,10 @@ declare module "sst" {
"type": "sst.cloudflare.Astro"
"url": string
}
"WebApp": {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string
@@ -118,6 +118,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS6": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.0.193",
"version": "1.0.202",
"type": "module",
"scripts": {
"typecheck": "tsgo -b",
+2
View File
@@ -13,6 +13,7 @@ import { createMenu } from "./menu"
import { check, Update } from "@tauri-apps/plugin-updater"
import { invoke } from "@tauri-apps/api/core"
import { relaunch } from "@tauri-apps/plugin-process"
import pkg from "../package.json"
const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
@@ -25,6 +26,7 @@ let update: Update | null = null
const platform: Platform = {
platform: "tauri",
version: pkg.version,
async openDirectoryPickerDialog(opts) {
const result = await open({
+2 -1
View File
@@ -9,6 +9,7 @@
"jsx": "preserve",
"jsxImportSource": "solid-js",
"allowJs": true,
"resolveJsonModule": true,
"strict": true,
"isolatedModules": true,
"noEmit": true,
@@ -16,5 +17,5 @@
"outDir": "node_modules/.ts-dist"
},
"references": [{ "path": "../app" }],
"include": ["src"]
"include": ["src", "package.json"]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.0.193",
"version": "1.0.202",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -13,7 +13,7 @@ export default createHandler(() => (
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
{assets}
</head>
<body class="antialiased overscroll-none select-none text-12-regular">
<body class="antialiased overscroll-none text-12-regular">
<div id="app">{children}</div>
{scripts}
</body>
@@ -25,7 +25,7 @@ import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/
import { Diff as SSRDiff } from "@opencode-ai/ui/diff-ssr"
import { clientOnly } from "@solidjs/start"
import { type IconName } from "@opencode-ai/ui/icons/provider"
import { Meta } from "@solidjs/meta"
import { Meta, Title } from "@solidjs/meta"
import { Base64 } from "js-base64"
const ClientOnlyDiff = clientOnly(() => import("@opencode-ai/ui/diff").then((m) => ({ default: m.Diff })))
@@ -202,6 +202,9 @@ export default function () {
return (
<>
<Show when={info().title}>
<Title>{info().title} | OpenCode</Title>
</Show>
<Meta name="description" content="opencode - The AI coding agent built for the terminal." />
<Meta property="og:image" content={ogImage()} />
<Meta name="twitter:image" content={ogImage()} />
+8 -4
View File
@@ -42,10 +42,6 @@ declare module "sst" {
"type": "sst.sst.Linkable"
"username": string
}
"Desktop": {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"EMAILOCTOPUS_API_KEY": {
"type": "sst.sst.Secret"
"value": string
@@ -98,6 +94,10 @@ declare module "sst" {
"type": "sst.cloudflare.Astro"
"url": string
}
"WebApp": {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string
@@ -118,6 +118,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS6": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.0.193"
version = "1.0.202"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/sst/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.193/opencode-darwin-arm64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.202/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.193/opencode-darwin-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.202/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.193/opencode-linux-arm64.tar.gz"
archive = "https://github.com/sst/opencode/releases/download/v1.0.202/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.193/opencode-linux-x64.tar.gz"
archive = "https://github.com/sst/opencode/releases/download/v1.0.202/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.193/opencode-windows-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.202/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.0.193",
"version": "1.0.202",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+8 -4
View File
@@ -42,10 +42,6 @@ declare module "sst" {
"type": "sst.sst.Linkable"
"username": string
}
"Desktop": {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"EMAILOCTOPUS_API_KEY": {
"type": "sst.sst.Secret"
"value": string
@@ -98,6 +94,10 @@ declare module "sst" {
"type": "sst.cloudflare.Astro"
"url": string
}
"WebApp": {
"type": "sst.cloudflare.StaticSite"
"url": string
}
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string
@@ -118,6 +118,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS6": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.193",
"version": "1.0.202",
"name": "opencode",
"type": "module",
"private": true,
+17 -8
View File
@@ -7,7 +7,12 @@ import { graphql } from "@octokit/graphql"
import * as core from "@actions/core"
import * as github from "@actions/github"
import type { Context } from "@actions/github/lib/context"
import type { IssueCommentEvent, PullRequestReviewCommentEvent, PullRequestEvent } from "@octokit/webhooks-types"
import type {
IssueCommentEvent,
PullRequestReviewCommentEvent,
WorkflowRunEvent,
PullRequestEvent,
} from "@octokit/webhooks-types"
import { UI } from "../ui"
import { cmd } from "./cmd"
import { ModelsDev } from "../../provider/models"
@@ -401,10 +406,12 @@ export const GithubRunCommand = cmd({
const oidcBaseUrl = normalizeOidcBaseUrl()
const { owner, repo } = context.repo
// For schedule events, payload has no issue/comment data
const payload = isCommentEvent
? (context.payload as IssueCommentEvent | PullRequestReviewCommentEvent)
: undefined
const issueEvent = payload && isIssueCommentEvent(payload) ? payload : undefined
const payload = context.payload as
| IssueCommentEvent
| PullRequestReviewCommentEvent
| WorkflowRunEvent
| PullRequestEvent
const issueEvent = isIssueCommentEvent(payload) ? payload : undefined
const actor = isScheduleEvent ? undefined : context.actor
const issueId = isScheduleEvent
@@ -423,7 +430,9 @@ export const GithubRunCommand = cmd({
let shareId: string | undefined
let exitCode = 0
type PromptFiles = Awaited<ReturnType<typeof getUserPrompt>>["promptFiles"]
const triggerCommentId = payload?.comment.id
const triggerCommentId = isCommentEvent
? (payload as IssueCommentEvent | PullRequestReviewCommentEvent).comment.id
: undefined
const useGithubToken = normalizeUseGithubToken()
const commentType = isCommentEvent
? context.eventName === "pull_request_review_comment"
@@ -619,7 +628,7 @@ export const GithubRunCommand = cmd({
}
function isIssueCommentEvent(
event: IssueCommentEvent | PullRequestReviewCommentEvent,
event: IssueCommentEvent | PullRequestReviewCommentEvent | WorkflowRunEvent | PullRequestEvent,
): event is IssueCommentEvent {
return "issue" in event
}
@@ -664,7 +673,7 @@ export const GithubRunCommand = cmd({
if (!isCommentEvent) {
return "Review this pull request"
}
const body = payload!.comment.body.trim()
const body = (payload as IssueCommentEvent | PullRequestReviewCommentEvent).comment.body.trim()
const bodyLower = body.toLowerCase()
if (mentions.some((m) => bodyLower === m)) {
if (reviewContext) {
+3 -3
View File
@@ -31,15 +31,15 @@ export const ImportCommand = cmd({
const isUrl = args.file.startsWith("http://") || args.file.startsWith("https://")
if (isUrl) {
const urlMatch = args.file.match(/https?:\/\/opencode\.ai\/s\/([a-zA-Z0-9_-]+)/)
const urlMatch = args.file.match(/https?:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/)
if (!urlMatch) {
process.stdout.write(`Invalid URL format. Expected: https://opencode.ai/s/<slug>`)
process.stdout.write(`Invalid URL format. Expected: https://opncd.ai/s/<slug>`)
process.stdout.write(EOL)
return
}
const slug = urlMatch[1]
const response = await fetch(`https://api.opencode.ai/share_data?id=${slug}`)
const response = await fetch(`https://opncd.ai/api/share/${slug}`)
if (!response.ok) {
process.stdout.write(`Failed to fetch share data: ${response.statusText}`)
+1 -1
View File
@@ -65,7 +65,7 @@ export const PrCommand = cmd({
// Check for opencode session link in PR body
if (prInfo && prInfo.body) {
const sessionMatch = prInfo.body.match(/https:\/\/opencode\.ai\/s\/([a-zA-Z0-9_-]+)/)
const sessionMatch = prInfo.body.match(/https:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/)
if (sessionMatch) {
const sessionUrl = sessionMatch[0]
UI.println(`Found opencode session: ${sessionUrl}`)
+27 -10
View File
@@ -24,6 +24,7 @@ import { ThemeProvider, useTheme } from "@tui/context/theme"
import { Home } from "@tui/routes/home"
import { Session } from "@tui/routes/session"
import { PromptHistoryProvider } from "./component/prompt/history"
import { PromptStashProvider } from "./component/prompt/stash"
import { DialogAlert } from "./ui/dialog-alert"
import { ToastProvider, useToast } from "./ui/toast"
import { ExitProvider, useExit } from "./context/exit"
@@ -120,15 +121,17 @@ export function tui(input: { url: string; args: Args; onExit?: () => Promise<voi
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<DialogProvider>
<CommandProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App />
</PromptRefProvider>
</PromptHistoryProvider>
</CommandProvider>
</DialogProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App />
</PromptRefProvider>
</PromptHistoryProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
@@ -176,6 +179,20 @@ function App() {
const exit = useExit()
const promptRef = usePromptRef()
// Wire up console copy-to-clipboard via opentui's onCopySelection callback
renderer.console.onCopySelection = async (text: string) => {
if (!text || text.length === 0) return
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
const finalOsc52 = process.env["TMUX"] ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
// @ts-expect-error writeOut is not in type definitions
renderer.writeOut(finalOsc52)
await Clipboard.copy(text)
.then(() => toast.show({ message: "Copied to clipboard", variant: "info" }))
.catch(toast.error)
renderer.clearSelection()
}
const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true))
createEffect(() => {
@@ -444,7 +461,7 @@ function App() {
{
title: "Toggle console",
category: "System",
value: "app.fps",
value: "app.console",
onSelect: (dialog) => {
renderer.console.toggle()
dialog.clear()
@@ -45,6 +45,7 @@ function init() {
useKeyboard((evt) => {
if (suspended()) return
if (dialog.stack.length > 0) return
for (const option of options()) {
if (option.keybind && keybind.match(option.keybind, evt)) {
evt.preventDefault()
@@ -6,6 +6,7 @@ import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { Keybind } from "@/util/keybind"
import * as fuzzysort from "fuzzysort"
export function useConnected() {
const sync = useSync()
@@ -19,6 +20,7 @@ export function DialogModel(props: { providerID?: string }) {
const sync = useSync()
const dialog = useDialog()
const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
const [query, setQuery] = createSignal("")
const connected = useConnected()
const providers = createDialogProviderOptions()
@@ -30,7 +32,7 @@ export function DialogModel(props: { providerID?: string }) {
})
const options = createMemo(() => {
const query = ref()?.filter
const q = query()
const favorites = showExtra() ? local.model.favorite() : []
const recents = local.model.recent()
@@ -42,148 +44,151 @@ export function DialogModel(props: { providerID?: string }) {
.slice(0, 5)
: []
const favoriteOptions = !query
? favorites.flatMap((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)
if (!provider) return []
const model = provider.models[item.modelID]
if (!model) return []
return [
{
key: item,
value: {
const favoriteOptions = favorites.flatMap((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)
if (!provider) return []
const model = provider.models[item.modelID]
if (!model) return []
return [
{
key: item,
value: {
providerID: provider.id,
modelID: model.id,
},
title: model.name ?? item.modelID,
description: provider.name,
category: "Favorites",
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model.id,
},
title: model.name ?? item.modelID,
description: provider.name,
category: "Favorites",
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
{ recent: true },
)
},
},
]
})
const recentOptions = recentList.flatMap((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)
if (!provider) return []
const model = provider.models[item.modelID]
if (!model) return []
return [
{
key: item,
value: {
providerID: provider.id,
modelID: model.id,
},
title: model.name ?? item.modelID,
description: provider.name,
category: "Recent",
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model.id,
},
{ recent: true },
)
},
},
]
})
const providerOptions = pipe(
sync.data.provider,
sortBy(
(provider) => provider.id !== "opencode",
(provider) => provider.name,
),
flatMap((provider) =>
pipe(
provider.models,
entries(),
filter(([_, info]) => info.status !== "deprecated"),
filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)),
map(([model, info]) => {
const value = {
providerID: provider.id,
modelID: model,
}
return {
value,
title: info.name ?? model,
description: favorites.some(
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
)
? "(Favorite)"
: undefined,
category: connected() ? provider.name : undefined,
disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect() {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model.id,
modelID: model,
},
{ recent: true },
)
},
},
]
})
: []
const recentOptions = !query
? recentList.flatMap((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)
if (!provider) return []
const model = provider.models[item.modelID]
if (!model) return []
return [
{
key: item,
value: {
providerID: provider.id,
modelID: model.id,
},
title: model.name ?? item.modelID,
description: provider.name,
category: "Recent",
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model.id,
},
{ recent: true },
)
},
},
]
})
: []
return [
...favoriteOptions,
...recentOptions,
...pipe(
sync.data.provider,
sortBy(
(provider) => provider.id !== "opencode",
(provider) => provider.name,
),
flatMap((provider) =>
pipe(
provider.models,
entries(),
filter(([_, info]) => info.status !== "deprecated"),
filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)),
map(([model, info]) => {
const value = {
providerID: provider.id,
modelID: model,
}
return {
value,
title: info.name ?? model,
description: favorites.some(
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
)
? "(Favorite)"
: undefined,
category: connected() ? provider.name : undefined,
disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect() {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model,
},
{ recent: true },
)
},
}
}),
filter((x) => {
if (query) return true
const value = x.value
const inFavorites = favorites.some(
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
)
if (inFavorites) return false
const inRecents = recents.some(
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
)
if (inRecents) return false
return true
}),
sortBy(
(x) => x.footer !== "Free",
(x) => x.title,
),
}
}),
filter((x) => {
const value = x.value
const inFavorites = favorites.some(
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
)
if (inFavorites) return false
const inRecents = recents.some(
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
)
if (inRecents) return false
return true
}),
sortBy(
(x) => x.footer !== "Free",
(x) => x.title,
),
),
),
...(!connected()
? pipe(
providers(),
map((option) => {
return {
...option,
category: "Popular providers",
}
}),
take(6),
)
: []),
]
)
const popularProviders = !connected()
? pipe(
providers(),
map((option) => {
return {
...option,
category: "Popular providers",
}
}),
take(6),
)
: []
// Apply fuzzy filtering to each section separately, maintaining section order
if (q) {
const filteredFavorites = fuzzysort.go(q, favoriteOptions, { keys: ["title"] }).map((x) => x.obj)
const filteredRecents = fuzzysort.go(q, recentOptions, { keys: ["title"] }).map((x) => x.obj)
const filteredProviders = fuzzysort.go(q, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj)
const filteredPopular = fuzzysort.go(q, popularProviders, { keys: ["title"] }).map((x) => x.obj)
return [...filteredFavorites, ...filteredRecents, ...filteredProviders, ...filteredPopular]
}
return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
})
const provider = createMemo(() =>
@@ -215,6 +220,8 @@ export function DialogModel(props: { providerID?: string }) {
},
]}
ref={setRef}
onFilter={setQuery}
skipFilter={true}
title={title()}
current={local.model.current()}
options={options()}
@@ -0,0 +1,86 @@
import { useDialog } from "@tui/ui/dialog"
import { DialogSelect } from "@tui/ui/dialog-select"
import { createMemo, createSignal } from "solid-js"
import { Locale } from "@/util/locale"
import { Keybind } from "@/util/keybind"
import { useTheme } from "../context/theme"
import { usePromptStash, type StashEntry } from "./prompt/stash"
function getRelativeTime(timestamp: number): string {
const now = Date.now()
const diff = now - timestamp
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (seconds < 60) return "just now"
if (minutes < 60) return `${minutes}m ago`
if (hours < 24) return `${hours}h ago`
if (days < 7) return `${days}d ago`
return Locale.datetime(timestamp)
}
function getStashPreview(input: string, maxLength: number = 50): string {
const firstLine = input.split("\n")[0].trim()
return Locale.truncate(firstLine, maxLength)
}
export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) {
const dialog = useDialog()
const stash = usePromptStash()
const { theme } = useTheme()
const [toDelete, setToDelete] = createSignal<number>()
const options = createMemo(() => {
const entries = stash.list()
// Show most recent first
return entries
.map((entry, index) => {
const isDeleting = toDelete() === index
const lineCount = (entry.input.match(/\n/g)?.length ?? 0) + 1
return {
title: isDeleting ? "Press ctrl+d again to confirm" : getStashPreview(entry.input),
bg: isDeleting ? theme.error : undefined,
value: index,
description: getRelativeTime(entry.timestamp),
footer: lineCount > 1 ? `~${lineCount} lines` : undefined,
}
})
.toReversed()
})
return (
<DialogSelect
title="Stash"
options={options()}
onMove={() => {
setToDelete(undefined)
}}
onSelect={(option) => {
const entries = stash.list()
const entry = entries[option.value]
if (entry) {
stash.remove(option.value)
props.onSelect(entry)
}
dialog.clear()
}}
keybind={[
{
keybind: Keybind.parse("ctrl+d")[0],
title: "delete",
onTrigger: (option) => {
if (toDelete() === option.value) {
stash.remove(option.value)
setToDelete(undefined)
return
}
setToDelete(option.value)
},
},
]}
/>
)
}
@@ -0,0 +1,85 @@
import { createMemo, createSignal, For } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { useKeybind } from "@tui/context/keybind"
import { TIPS } from "./tips"
import { EmptyBorder } from "./border"
type TipPart = { text: string; highlight: boolean }
function parseTip(tip: string): TipPart[] {
const parts: TipPart[] = []
const regex = /\{highlight\}(.*?)\{\/highlight\}/g
let lastIndex = 0
let match
while ((match = regex.exec(tip)) !== null) {
if (match.index > lastIndex) {
parts.push({ text: tip.slice(lastIndex, match.index), highlight: false })
}
parts.push({ text: match[1], highlight: true })
lastIndex = regex.lastIndex
}
if (lastIndex < tip.length) {
parts.push({ text: tip.slice(lastIndex), highlight: false })
}
return parts
}
const [tipIndex, setTipIndex] = createSignal(Math.floor(Math.random() * TIPS.length))
export function randomizeTip() {
setTipIndex(Math.floor(Math.random() * TIPS.length))
}
const BOX_WIDTH = 42
const TITLE = " 🅘 Did you know? "
export function DidYouKnow() {
const { theme } = useTheme()
const keybind = useKeybind()
const tipParts = createMemo(() => parseTip(TIPS[tipIndex()]))
const dashes = createMemo(() => {
// ╭─ + title + ─...─ + ╮ = BOX_WIDTH
// 1 + 1 + title.length + dashes + 1 = BOX_WIDTH
return Math.max(0, BOX_WIDTH - 2 - TITLE.length - 1)
})
return (
<box position="absolute" bottom={3} right={2} width={BOX_WIDTH}>
<text>
<span style={{ fg: theme.border }}></span>
<span style={{ fg: theme.text }}>{TITLE}</span>
<span style={{ fg: theme.border }}>{"─".repeat(dashes())}</span>
</text>
<box
border={["left", "right", "bottom"]}
borderColor={theme.border}
customBorderChars={{
...EmptyBorder,
bottomLeft: "╰",
bottomRight: "╯",
horizontal: "─",
vertical: "│",
}}
>
<box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
<text>
<For each={tipParts()}>
{(part) => <span style={{ fg: part.highlight ? theme.text : theme.textMuted }}>{part.text}</span>}
</For>
</text>
</box>
</box>
<box flexDirection="row" justifyContent="flex-end">
<text>
<span style={{ fg: theme.text }}>{keybind.print("tips_toggle")}</span>
<span style={{ fg: theme.textMuted }}> hide tips</span>
</text>
</box>
</box>
)
}
@@ -12,9 +12,11 @@ import { createStore, produce } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
import { Keybind } from "@/util/keybind"
import { usePromptHistory, type PromptInfo } from "./history"
import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
import { useCommandDialog } from "../dialog-command"
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
import { useRenderer } from "@opentui/solid"
import { Editor } from "@tui/util/editor"
import { useExit } from "../../context/exit"
import { Clipboard } from "../../util/clipboard"
@@ -118,11 +120,9 @@ export function Prompt(props: PromptProps) {
const toast = useToast()
const status = createMemo(() => sync.data.session_status?.[props.sessionID ?? ""] ?? { type: "idle" })
const history = usePromptHistory()
const stash = usePromptStash()
const command = useCommandDialog()
const renderer = useRenderer()
const dimensions = useTerminalDimensions()
const tall = createMemo(() => dimensions().height > 40)
const wide = createMemo(() => dimensions().width > 120)
const { theme, syntax } = useTheme()
function promptModelWarning() {
@@ -151,6 +151,37 @@ export function Prompt(props: PromptProps) {
const pasteStyleId = syntax().getStyleId("extmark.paste")!
let promptPartTypeId: number
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
input.insertText(evt.properties.text)
setTimeout(() => {
input.getLayoutNode().markDirty()
input.gotoBufferEnd()
renderer.requestRender()
}, 0)
})
createEffect(() => {
if (props.disabled) input.cursorColor = theme.backgroundElement
if (!props.disabled) input.cursorColor = theme.text
})
const [store, setStore] = createStore<{
prompt: PromptInfo
mode: "normal" | "shell"
extmarkToPartIndex: Map<number, number>
interrupt: number
placeholder: number
}>({
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
prompt: {
input: "",
parts: [],
},
mode: "normal",
extmarkToPartIndex: new Map(),
interrupt: 0,
})
command.register(() => {
return [
{
@@ -311,37 +342,6 @@ export function Prompt(props: PromptProps) {
]
})
sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
input.insertText(evt.properties.text)
setTimeout(() => {
input.getLayoutNode().markDirty()
input.gotoBufferEnd()
renderer.requestRender()
}, 0)
})
createEffect(() => {
if (props.disabled) input.cursorColor = theme.backgroundElement
if (!props.disabled) input.cursorColor = theme.text
})
const [store, setStore] = createStore<{
prompt: PromptInfo
mode: "normal" | "shell"
extmarkToPartIndex: Map<number, number>
interrupt: number
placeholder: number
}>({
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
prompt: {
input: "",
parts: [],
},
mode: "normal",
extmarkToPartIndex: new Map(),
interrupt: 0,
})
createEffect(() => {
input.focus()
})
@@ -428,6 +428,61 @@ export function Prompt(props: PromptProps) {
)
}
command.register(() => [
{
title: "Stash prompt",
value: "prompt.stash",
category: "Prompt",
disabled: !store.prompt.input,
onSelect: (dialog) => {
if (!store.prompt.input) return
stash.push({
input: store.prompt.input,
parts: store.prompt.parts,
})
input.extmarks.clear()
input.clear()
setStore("prompt", { input: "", parts: [] })
setStore("extmarkToPartIndex", new Map())
dialog.clear()
},
},
{
title: "Stash pop",
value: "prompt.stash.pop",
category: "Prompt",
disabled: stash.list().length === 0,
onSelect: (dialog) => {
const entry = stash.pop()
if (entry) {
input.setText(entry.input)
setStore("prompt", { input: entry.input, parts: entry.parts })
restoreExtmarksFromParts(entry.parts)
input.gotoBufferEnd()
}
dialog.clear()
},
},
{
title: "Stash list",
value: "prompt.stash.list",
category: "Prompt",
disabled: stash.list().length === 0,
onSelect: (dialog) => {
dialog.replace(() => (
<DialogStash
onSelect={(entry) => {
input.setText(entry.input)
setStore("prompt", { input: entry.input, parts: entry.parts })
restoreExtmarksFromParts(entry.parts)
input.gotoBufferEnd()
}}
/>
))
},
},
])
props.ref?.({
get focused() {
return input.focused
@@ -872,6 +927,13 @@ export function Prompt(props: PromptProps) {
pasteText(pastedContent, `[Pasted ~${lineCount} lines]`)
return
}
// Force layout update and render for the pasted content
setTimeout(() => {
input.getLayoutNode().markDirty()
input.gotoBufferEnd()
renderer.requestRender()
}, 0)
}}
ref={(r: TextareaRenderable) => {
input = r
@@ -884,21 +946,19 @@ export function Prompt(props: PromptProps) {
cursorColor={theme.text}
syntaxStyle={syntax()}
/>
<Show when={tall()}>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
<text fg={highlight()}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model}
</text>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
</box>
</Show>
</box>
</Show>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
<text fg={highlight()}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model}
</text>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
</box>
</Show>
</box>
</box>
</box>
<box
@@ -928,123 +988,101 @@ export function Prompt(props: PromptProps) {
/>
</box>
<box flexDirection="row" justifyContent="space-between">
<Switch>
<Match when={status().type !== "idle"}>
<box
flexDirection="row"
gap={1}
flexGrow={1}
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
{/* @ts-ignore // SpinnerOptions doesn't support marginLeft */}
<spinner marginLeft={1} color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
return s
})
const message = createMemo(() => {
const r = retry()
if (!r) return
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
return "gemini is way too hot right now"
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
return r.message
})
const isTruncated = createMemo(() => {
const r = retry()
if (!r) return false
return r.message.length > 120
})
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
const next = retry()?.next
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
}, 1000)
<Show when={status().type !== "idle"} fallback={<text />}>
<box
flexDirection="row"
gap={1}
flexGrow={1}
justifyContent={status().type === "retry" ? "space-between" : "flex-start"}
>
<box flexShrink={0} flexDirection="row" gap={1}>
{/* @ts-ignore // SpinnerOptions doesn't support marginLeft */}
<spinner marginLeft={1} color={spinnerDef().color} frames={spinnerDef().frames} interval={40} />
<box flexDirection="row" gap={1} flexShrink={0}>
{(() => {
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
return s
})
const message = createMemo(() => {
const r = retry()
if (!r) return
if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
return "gemini is way too hot right now"
if (r.message.length > 80) return r.message.slice(0, 80) + "..."
return r.message
})
const isTruncated = createMemo(() => {
const r = retry()
if (!r) return false
return r.message.length > 120
})
const [seconds, setSeconds] = createSignal(0)
onMount(() => {
const timer = setInterval(() => {
const next = retry()?.next
if (next) setSeconds(Math.round((next - Date.now()) / 1000))
}, 1000)
onCleanup(() => {
clearTimeout(timer)
})
onCleanup(() => {
clearInterval(timer)
})
const handleMessageClick = () => {
const r = retry()
if (!r) return
if (isTruncated()) {
DialogAlert.show(dialog, "Retry Error", r.message)
}
})
const handleMessageClick = () => {
const r = retry()
if (!r) return
if (isTruncated()) {
DialogAlert.show(dialog, "Retry Error", r.message)
}
}
const retryText = () => {
const r = retry()
if (!r) return ""
const baseMessage = message()
const truncatedHint = isTruncated() ? " (click to expand)" : ""
const retryInfo = ` [retrying ${seconds() > 0 ? `in ${seconds()}s ` : ""}attempt #${r.attempt}]`
return baseMessage + truncatedHint + retryInfo
}
const retryText = () => {
const r = retry()
if (!r) return ""
const baseMessage = message()
const truncatedHint = isTruncated() ? " (click to expand)" : ""
const retryInfo = ` [retrying ${seconds() > 0 ? `in ${seconds()}s ` : ""}attempt #${r.attempt}]`
return baseMessage + truncatedHint + retryInfo
}
return (
<Show when={retry()}>
<box onMouseUp={handleMessageClick}>
<text fg={theme.error}>{retryText()}</text>
</box>
</Show>
)
})()}
</box>
return (
<Show when={retry()}>
<box onMouseUp={handleMessageClick}>
<text fg={theme.error}>{retryText()}</text>
</box>
</Show>
)
})()}
</box>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
</box>
</Match>
<Match when={!tall()}>
<box flexDirection="row" gap={1}>
<text fg={highlight()}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model}
</text>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
</box>
</Show>
</box>
</Match>
</Switch>
<box gap={2} flexDirection="row" marginLeft="auto">
<Switch>
<Match when={store.mode === "normal"}>
<Show when={wide()}>
<text fg={store.interrupt > 0 ? theme.primary : theme.text}>
esc{" "}
<span style={{ fg: store.interrupt > 0 ? theme.primary : theme.textMuted }}>
{store.interrupt > 0 ? "again to interrupt" : "interrupt"}
</span>
</text>
</box>
</Show>
<Show when={status().type !== "retry"}>
<box gap={2} flexDirection="row">
<Switch>
<Match when={store.mode === "normal"}>
<text fg={theme.text}>
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>switch agent</span>
</text>
</Show>
<Show when={!wide()}>
<text fg={theme.text}>
{keybind.print("sidebar_toggle")} <span style={{ fg: theme.textMuted }}>sidebar</span>
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Show>
<text fg={theme.text}>
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
</text>
</Match>
<Match when={store.mode === "shell"}>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
</text>
</Match>
</Switch>
</box>
</Match>
<Match when={store.mode === "shell"}>
<text fg={theme.text}>
esc <span style={{ fg: theme.textMuted }}>exit shell mode</span>
</text>
</Match>
</Switch>
</box>
</Show>
</box>
</box>
</>
@@ -0,0 +1,101 @@
import path from "path"
import { Global } from "@/global"
import { onMount } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { clone } from "remeda"
import { createSimpleContext } from "../../context/helper"
import { appendFile, writeFile } from "fs/promises"
import type { PromptInfo } from "./history"
export type StashEntry = {
input: string
parts: PromptInfo["parts"]
timestamp: number
}
const MAX_STASH_ENTRIES = 50
export const { use: usePromptStash, provider: PromptStashProvider } = createSimpleContext({
name: "PromptStash",
init: () => {
const stashFile = Bun.file(path.join(Global.Path.state, "prompt-stash.jsonl"))
onMount(async () => {
const text = await stashFile.text().catch(() => "")
const lines = text
.split("\n")
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line)
} catch {
return null
}
})
.filter((line): line is StashEntry => line !== null)
.slice(-MAX_STASH_ENTRIES)
setStore("entries", lines)
// Rewrite file with only valid entries to self-heal corruption
if (lines.length > 0) {
const content = lines.map((line) => JSON.stringify(line)).join("\n") + "\n"
writeFile(stashFile.name!, content).catch(() => {})
}
})
const [store, setStore] = createStore({
entries: [] as StashEntry[],
})
return {
list() {
return store.entries
},
push(entry: Omit<StashEntry, "timestamp">) {
const stash = clone({ ...entry, timestamp: Date.now() })
let trimmed = false
setStore(
produce((draft) => {
draft.entries.push(stash)
if (draft.entries.length > MAX_STASH_ENTRIES) {
draft.entries = draft.entries.slice(-MAX_STASH_ENTRIES)
trimmed = true
}
}),
)
if (trimmed) {
const content = store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n"
writeFile(stashFile.name!, content).catch(() => {})
return
}
appendFile(stashFile.name!, JSON.stringify(stash) + "\n").catch(() => {})
},
pop() {
if (store.entries.length === 0) return undefined
const entry = store.entries[store.entries.length - 1]
setStore(
produce((draft) => {
draft.entries.pop()
}),
)
const content =
store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : ""
writeFile(stashFile.name!, content).catch(() => {})
return entry
},
remove(index: number) {
if (index < 0 || index >= store.entries.length) return
setStore(
produce((draft) => {
draft.entries.splice(index, 1)
}),
)
const content =
store.entries.length > 0 ? store.entries.map((line) => JSON.stringify(line)).join("\n") + "\n" : ""
writeFile(stashFile.name!, content).catch(() => {})
},
}
},
})
@@ -0,0 +1,103 @@
export const TIPS = [
"Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files to your prompt.",
"Start a message with {highlight}!{/highlight} to run shell commands directly (e.g., {highlight}!ls -la{/highlight}).",
"Press {highlight}Tab{/highlight} to cycle between Build (full access) and Plan (read-only) agents.",
"Use {highlight}/undo{/highlight} to revert the last message and any file changes made by OpenCode.",
"Use {highlight}/redo{/highlight} to restore previously undone messages and file changes.",
"Run {highlight}/share{/highlight} to create a public link to your conversation at opencode.ai.",
"Drag and drop images into the terminal to add them as context for your prompts.",
"Press {highlight}Ctrl+V{/highlight} to paste images from your clipboard directly into the prompt.",
"Press {highlight}Ctrl+X E{/highlight} or {highlight}/editor{/highlight} to compose messages in your external editor.",
"Run {highlight}/init{/highlight} to auto-generate project rules based on your codebase structure.",
"Run {highlight}/models{/highlight} or {highlight}Ctrl+X M{/highlight} to see and switch between available AI models.",
"Use {highlight}/theme{/highlight} or {highlight}Ctrl+X T{/highlight} to preview and switch between 50+ built-in themes.",
"Press {highlight}Ctrl+X N{/highlight} or {highlight}/new{/highlight} to start a fresh conversation session.",
"Use {highlight}/sessions{/highlight} or {highlight}Ctrl+X L{/highlight} to list and continue previous conversations.",
"Run {highlight}/compact{/highlight} to summarize long sessions when approaching context limits.",
"Press {highlight}Ctrl+X X{/highlight} or {highlight}/export{/highlight} to save the conversation as Markdown.",
"Press {highlight}Ctrl+X Y{/highlight} to copy the assistant's last message to clipboard.",
"Press {highlight}Ctrl+P{/highlight} to see all available actions and commands.",
"Run {highlight}/connect{/highlight} to add API keys for 75+ supported LLM providers.",
"The default leader key is {highlight}Ctrl+X{/highlight}; combine with other keys for quick actions.",
"Press {highlight}F2{/highlight} to quickly switch between recently used models.",
"Press {highlight}Ctrl+X B{/highlight} to show/hide the sidebar panel.",
"Use {highlight}PageUp{/highlight}/{highlight}PageDown{/highlight} to navigate through conversation history.",
"Press {highlight}Ctrl+G{/highlight} or {highlight}Home{/highlight} to jump to the beginning of the conversation.",
"Press {highlight}Ctrl+Alt+G{/highlight} or {highlight}End{/highlight} to jump to the most recent message.",
"Press {highlight}Shift+Enter{/highlight} or {highlight}Ctrl+J{/highlight} to add newlines in your prompt.",
"Press {highlight}Ctrl+C{/highlight} when typing to clear the input field.",
"Press {highlight}Escape{/highlight} to stop the AI mid-response.",
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes.",
"Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents.",
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions.",
"Create {highlight}opencode.json{/highlight} in project root for project-specific settings.",
"Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config.",
"Add {highlight}$schema{/highlight} to your config for autocomplete in your editor.",
"Configure {highlight}model{/highlight} in config to set your default model.",
"Override any keybind in config via the {highlight}keybinds{/highlight} section.",
"Set any keybind to {highlight}none{/highlight} to disable it completely.",
"Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section.",
"OpenCode auto-handles OAuth for remote MCP servers requiring auth.",
"Add {highlight}.md{/highlight} files to {highlight}.opencode/command/{/highlight} to define reusable custom prompts.",
"Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input.",
"Use backticks in commands to inject shell output (e.g., {highlight}`git status`{/highlight}).",
"Add {highlight}.md{/highlight} files to {highlight}.opencode/agent/{/highlight} for specialized AI personas.",
"Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}bash{/highlight}, and {highlight}webfetch{/highlight} tools.",
'Use patterns like {highlight}"git *": "allow"{/highlight} for granular bash permissions.',
'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands.',
'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing.',
"OpenCode auto-formats files using prettier, gofmt, ruff, and more.",
'Set {highlight}"formatter": false{/highlight} in config to disable all auto-formatting.',
"Define custom formatter commands with file extensions in config.",
"OpenCode uses LSP servers for intelligent code analysis.",
"Create {highlight}.ts{/highlight} files in {highlight}.opencode/tool/{/highlight} to define new LLM tools.",
"Tool definitions can invoke scripts written in Python, Go, etc.",
"Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugin/{/highlight} for event hooks.",
"Use plugins to send OS notifications when sessions complete.",
"Create a plugin to prevent OpenCode from reading sensitive files.",
"Use {highlight}opencode run{/highlight} for non-interactive scripting.",
"Use {highlight}opencode run --continue{/highlight} to resume the last session.",
"Use {highlight}opencode run -f file.ts{/highlight} to attach files via CLI.",
"Use {highlight}--format json{/highlight} for machine-readable output in scripts.",
"Run {highlight}opencode serve{/highlight} for headless API access to OpenCode.",
"Use {highlight}opencode run --attach{/highlight} to connect to a running server for faster runs.",
"Run {highlight}opencode upgrade{/highlight} to update to the latest version.",
"Run {highlight}opencode auth list{/highlight} to see all configured providers.",
"Run {highlight}opencode agent create{/highlight} for guided agent creation.",
"Use {highlight}/opencode{/highlight} in GitHub issues/PRs to trigger AI actions.",
"Run {highlight}opencode github install{/highlight} to set up the GitHub workflow.",
"Comment {highlight}/opencode fix this{/highlight} on issues to auto-create PRs.",
"Comment {highlight}/oc{/highlight} on PR code lines for targeted code reviews.",
'Use {highlight}"theme": "system"{/highlight} to match your terminal\'s colors.',
"Create JSON theme files in {highlight}.opencode/themes/{/highlight} directory.",
"Themes support dark/light variants for both modes.",
"Reference ANSI colors 0-255 in custom themes.",
"Use {highlight}{env:VAR_NAME}{/highlight} syntax to reference environment variables in config.",
"Use {highlight}{file:path}{/highlight} to include file contents in config values.",
"Use {highlight}instructions{/highlight} in config to load additional rules files.",
"Set agent {highlight}temperature{/highlight} from 0.0 (focused) to 1.0 (creative).",
"Configure {highlight}maxSteps{/highlight} to limit agentic iterations per request.",
'Set {highlight}"tools": {"bash": false}{/highlight} to disable specific tools.',
'Use {highlight}"mcp_*": false{/highlight} to disable all tools from an MCP server.',
"Override global tool settings per agent configuration.",
'Set {highlight}"share": "auto"{/highlight} to automatically share all sessions.',
'Set {highlight}"share": "disabled"{/highlight} to prevent any session sharing.',
"Run {highlight}/unshare{/highlight} to remove a session from public access.",
"Permission {highlight}doom_loop{/highlight} prevents infinite tool call loops.",
"Permission {highlight}external_directory{/highlight} protects files outside project.",
"Run {highlight}opencode debug config{/highlight} to troubleshoot configuration.",
"Use {highlight}--print-logs{/highlight} flag to see detailed logs in stderr.",
"Press {highlight}Ctrl+X G{/highlight} or {highlight}/timeline{/highlight} to jump to specific messages.",
"Press {highlight}Ctrl+X H{/highlight} to toggle code block visibility in messages.",
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info.",
"Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling.",
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight}).",
"Run {highlight}docker run -it --rm ghcr.io/sst/opencode{/highlight} for containerized use.",
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models.",
"Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing.",
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs.",
"Run {highlight}/help{/highlight} or {highlight}Ctrl+X H{/highlight} to show the help dialog.",
"Use {highlight}/details{/highlight} to toggle tool execution details visibility.",
"Use {highlight}/rename{/highlight} to rename the current session.",
"Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell.",
]
@@ -13,16 +13,16 @@ export function TodoItem(props: TodoItemProps) {
<text
flexShrink={0}
style={{
fg: props.status === "in_progress" ? theme.success : theme.textMuted,
fg: props.status === "in_progress" ? theme.warning : theme.textMuted,
}}
>
[{props.status === "completed" ? "✓" : " "}]{" "}
[{props.status === "completed" ? "✓" : props.status === "in_progress" ? "•" : " "}]{" "}
</text>
<text
flexGrow={1}
wrapMode="word"
style={{
fg: props.status === "in_progress" ? theme.success : theme.textMuted,
fg: props.status === "in_progress" ? theme.warning : theme.textMuted,
}}
>
{props.content}
@@ -2,23 +2,28 @@ import { Prompt, type PromptRef } from "@tui/component/prompt"
import { createMemo, Match, onMount, Show, Switch } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { Logo } from "../component/logo"
import { DidYouKnow, randomizeTip } from "../component/did-you-know"
import { Locale } from "@/util/locale"
import { useSync } from "../context/sync"
import { Toast } from "../ui/toast"
import { useArgs } from "../context/args"
import { useDirectory } from "../context/directory"
import { useRoute, useRouteData } from "@tui/context/route"
import { useRouteData } from "@tui/context/route"
import { usePromptRef } from "../context/prompt"
import { Installation } from "@/installation"
import { useKV } from "../context/kv"
import { useCommandDialog } from "../component/dialog-command"
// TODO: what is the best way to do this?
let once = false
export function Home() {
const sync = useSync()
const kv = useKV()
const { theme } = useTheme()
const route = useRouteData("home")
const promptRef = usePromptRef()
const command = useCommandDialog()
const mcp = createMemo(() => Object.keys(sync.data.mcp).length > 0)
const mcpError = createMemo(() => {
return Object.values(sync.data.mcp).some((x) => x.status === "failed")
@@ -28,6 +33,28 @@ export function Home() {
return Object.values(sync.data.mcp).filter((x) => x.status === "connected").length
})
const isFirstTimeUser = createMemo(() => sync.data.session.length === 0)
const tipsHidden = createMemo(() => kv.get("tips_hidden", false))
const showTips = createMemo(() => {
return false
// Don't show tips for first-time users
if (isFirstTimeUser()) return false
return !tipsHidden()
})
command.register(() => [
{
title: tipsHidden() ? "Show tips" : "Hide tips",
value: "tips.toggle",
keybind: "tips_toggle",
category: "System",
onSelect: (dialog) => {
kv.set("tips_hidden", !tipsHidden())
dialog.clear()
},
},
])
const Hint = (
<Show when={connectedMcpCount() > 0}>
<box flexShrink={0} flexDirection="row" gap={1}>
@@ -50,6 +77,7 @@ export function Home() {
let prompt: PromptRef
const args = useArgs()
onMount(() => {
randomizeTip()
if (once) return
if (route.initialPrompt) {
prompt.set(route.initialPrompt)
@@ -77,6 +105,11 @@ export function Home() {
</box>
<Toast />
</box>
<Show when={!isFirstTimeUser()}>
<Show when={showTips()}>
<DidYouKnow />
</Show>
</Show>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexDirection="row" flexShrink={0} gap={2}>
<text fg={theme.textMuted}>{directory()}</text>
<box gap={1} flexDirection="row" flexShrink={0}>
@@ -1,140 +1,124 @@
import { type Accessor, createMemo, Match, Show, Switch } from "solid-js"
import { useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { pipe, sumBy } from "remeda"
import { useTheme } from "@tui/context/theme"
import { EmptyBorder } from "@tui/component/border"
import type { Session } from "@opencode-ai/sdk/v2"
import { SplitBorder, EmptyBorder } from "@tui/component/border"
import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
import { useDirectory } from "../../context/directory"
import { useKeybind } from "../../context/keybind"
import { useTerminalDimensions } from "@opentui/solid"
const Title = (props: { session: Accessor<Session>; truncate?: boolean }) => {
const Title = (props: { session: Accessor<Session> }) => {
const { theme } = useTheme()
return (
<text fg={theme.text} wrapMode={props.truncate ? "none" : undefined} flexShrink={props.truncate ? 1 : 0}>
<text fg={theme.text}>
<span style={{ bold: true }}>#</span> <span style={{ bold: true }}>{props.session().title}</span>
</text>
)
}
const ContextInfo = (props: { context: Accessor<string | undefined>; cost: Accessor<string> }) => {
const { theme } = useTheme()
return (
<Show when={props.context()}>
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
{props.context()} ({props.cost()})
</text>
</Show>
)
}
export function Header() {
const route = useRouteData("session")
const sync = useSync()
const session = createMemo(() => sync.session.get(route.sessionID)!)
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const showShare = createMemo(() => shareEnabled() && !session()?.share?.url)
const cost = createMemo(() => {
const total = pipe(
messages(),
sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
)
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)
})
const context = createMemo(() => {
const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
if (!last) return
const total =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
let result = total.toLocaleString()
if (model?.limit.context) {
result += " " + Math.round((total / model.limit.context) * 100) + "%"
}
return result
})
const { theme } = useTheme()
const keybind = useKeybind()
const dimensions = useTerminalDimensions()
const tall = createMemo(() => dimensions().height > 40)
return (
<box flexShrink={0}>
<box
height={1}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={1}
{...SplitBorder}
border={["left"]}
borderColor={theme.border}
customBorderChars={{
...EmptyBorder,
vertical: theme.backgroundPanel.a !== 0 ? "╻" : " ",
}}
flexShrink={0}
backgroundColor={theme.backgroundPanel}
>
<box
height={1}
border={["top"]}
borderColor={theme.backgroundPanel}
customBorderChars={
theme.backgroundPanel.a !== 0
? {
...EmptyBorder,
horizontal: "▄",
}
: {
...EmptyBorder,
horizontal: " ",
}
}
/>
</box>
<box
border={["left"]}
borderColor={theme.border}
customBorderChars={{
...EmptyBorder,
vertical: "┃",
bottomLeft: "╹",
}}
>
<box
paddingTop={tall() ? 1 : 0}
paddingBottom={tall() ? 1 : 0}
paddingLeft={2}
paddingRight={1}
flexShrink={0}
flexGrow={1}
backgroundColor={theme.backgroundPanel}
>
<Switch>
<Match when={session()?.parentID}>
<box flexDirection="row" gap={2}>
<text fg={theme.text}>
<b>Subagent session</b>
</text>
<text fg={theme.text}>
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
</text>
<text fg={theme.text}>
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
</text>
<text fg={theme.text}>
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
</text>
<box flexGrow={1} flexShrink={1} />
<Show when={showShare()}>
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
/share{" "}
</text>
</Show>
</box>
</Match>
<Match when={true}>
<Switch>
<Match when={session()?.parentID}>
<box flexDirection="row" gap={2}>
<text fg={theme.text}>
<b>Subagent session</b>
</text>
<text fg={theme.text}>
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
</text>
<text fg={theme.text}>
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
</text>
<text fg={theme.text}>
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
</text>
<box flexGrow={1} flexShrink={1} />
<ContextInfo context={context} cost={cost} />
</box>
</Match>
<Match when={true}>
<box flexDirection="row" justifyContent="space-between" gap={1}>
<Title session={session} />
<ContextInfo context={context} cost={cost} />
</box>
<Show when={shareEnabled()}>
<box flexDirection="row" justifyContent="space-between" gap={1}>
<Title session={session} truncate={!tall()} />
<Show when={showShare()}>
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
/share{" "}
</text>
</Show>
<box flexGrow={1} flexShrink={1}>
<Switch>
<Match when={session().share?.url}>
<text fg={theme.textMuted} wrapMode="word">
{session().share!.url}
</text>
</Match>
<Match when={true}>
<text fg={theme.text} wrapMode="word">
/share <span style={{ fg: theme.textMuted }}>copy link</span>
</text>
</Match>
</Switch>
</box>
</box>
</Match>
</Switch>
</box>
</box>
<box
height={1}
border={["left"]}
borderColor={theme.border}
customBorderChars={{
...EmptyBorder,
vertical: theme.backgroundPanel.a !== 0 ? "╹" : " ",
}}
>
<box
height={1}
border={["bottom"]}
borderColor={theme.backgroundPanel}
customBorderChars={
theme.backgroundPanel.a !== 0
? {
...EmptyBorder,
horizontal: "▀",
}
: {
...EmptyBorder,
horizontal: " ",
}
}
/>
</Show>
</Match>
</Switch>
</box>
</box>
)
@@ -22,7 +22,6 @@ import {
ScrollBoxRenderable,
addDefaultParsers,
MacOSScrollAccel,
RGBA,
type ScrollAcceleration,
} from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt"
@@ -131,15 +130,13 @@ export function Session() {
const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
const wide = createMemo(() => dimensions().width > 120)
const tall = createMemo(() => dimensions().height > 40)
const sidebarVisible = createMemo(() => {
if (session()?.parentID) return false
if (sidebar() === "show") return true
if (sidebar() === "auto" && wide()) return true
return false
})
const sidebarOverlay = createMemo(() => sidebarVisible() && !wide())
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() && !sidebarOverlay() ? 42 : 0) - 4)
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
const scrollAcceleration = createMemo(() => {
const tui = sync.data.config.tui
@@ -965,7 +962,7 @@ export function Session() {
<box flexDirection="row">
<box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}>
<Show when={session()}>
<Show when={!sidebarVisible() || sidebarOverlay()}>
<Show when={!sidebarVisible()}>
<Header />
</Show>
<scrollbox
@@ -1095,33 +1092,15 @@ export function Session() {
sessionID={route.sessionID}
/>
</box>
<Show when={(!sidebarVisible() || sidebarOverlay()) && tall()}>
<Show when={!sidebarVisible()}>
<Footer />
</Show>
</Show>
<Toast />
</box>
<Show when={sidebarVisible() && !sidebarOverlay()}>
<Show when={sidebarVisible()}>
<Sidebar sessionID={route.sessionID} />
</Show>
<Show when={sidebarOverlay()}>
<box
position="absolute"
left={0}
top={0}
width={dimensions().width}
height={dimensions().height}
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
zIndex={100}
flexDirection="row"
justifyContent="flex-end"
onMouseUp={() => setSidebar("hide")}
>
<box onMouseUp={(e) => e.stopPropagation()}>
<Sidebar sessionID={route.sessionID} />
</box>
</box>
</Show>
</box>
</context.Provider>
)
@@ -73,7 +73,6 @@ export function Sidebar(props: { sessionID: string }) {
<box
backgroundColor={theme.backgroundPanel}
width={42}
height="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
@@ -19,6 +19,7 @@ export interface DialogSelectProps<T> {
onMove?: (option: DialogSelectOption<T>) => void
onFilter?: (query: string) => void
onSelect?: (option: DialogSelectOption<T>) => void
skipFilter?: boolean
keybind?: {
keybind: Keybind.Info
title: string
@@ -74,7 +75,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const result = pipe(
props.options,
filter((x) => x.disabled !== true),
(x) => (!needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj)),
(x) =>
!needle || props.skipFilter ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj),
)
return result
})
@@ -2,7 +2,7 @@ Please analyze this codebase and create an AGENTS.md file containing:
1. Build/lint/test commands - especially for running a single test
2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 150 lines long.
If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.
If there's already an AGENTS.md, improve it if it's located in ${path}
+1
View File
@@ -566,6 +566,7 @@ export namespace Config {
session_parent: z.string().optional().default("<leader>up").describe("Go to parent session"),
terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"),
terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"),
tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"),
})
.strict()
.meta({
+19 -1
View File
@@ -4,7 +4,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
import { type Tool as MCPToolDef, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"
import { Config } from "../config/config"
import { Log } from "../util/log"
import { NamedError } from "@opencode-ai/util/error"
@@ -15,6 +15,7 @@ import { withTimeout } from "@/util/timeout"
import { McpOAuthProvider } from "./oauth-provider"
import { McpOAuthCallback } from "./oauth-callback"
import { McpAuth } from "./auth"
import { BusEvent } from "../bus/bus-event"
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
import open from "open"
@@ -22,6 +23,13 @@ import open from "open"
export namespace MCP {
const log = Log.create({ service: "mcp" })
export const ToolsChanged = BusEvent.define(
"mcp.tools.changed",
z.object({
server: z.string(),
}),
)
export const Failed = NamedError.create(
"MCPFailed",
z.object({
@@ -76,6 +84,14 @@ export namespace MCP {
})
export type Status = z.infer<typeof Status>
// Register notification handlers for MCP client
function registerNotificationHandlers(client: MCPClient, serverName: string) {
client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
log.info("tools list changed notification received", { server: serverName })
Bus.publish(ToolsChanged, { server: serverName })
})
}
// Convert MCP tool definition to AI SDK Tool type
function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient): Tool {
const inputSchema = mcpTool.inputSchema
@@ -236,6 +252,7 @@ export namespace MCP {
version: Installation.VERSION,
})
await client.connect(transport)
registerNotificationHandlers(client, key)
mcpClient = client
log.info("connected", { key, transport: name })
status = { status: "connected" }
@@ -308,6 +325,7 @@ export namespace MCP {
version: Installation.VERSION,
})
await client.connect(transport)
registerNotificationHandlers(client, key)
mcpClient = client
status = {
status: "connected",
@@ -338,6 +338,45 @@ export namespace Provider {
},
}
},
"cloudflare-ai-gateway": async (input) => {
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
const gateway = Env.get("CLOUDFLARE_GATEWAY_ID")
if (!accountId || !gateway) return { autoload: false }
// Get API token from env or auth prompt
const apiToken = await (async () => {
const envToken = Env.get("CLOUDFLARE_API_TOKEN")
if (envToken) return envToken
const auth = await Auth.get(input.id)
if (auth?.type === "api") return auth.key
return undefined
})()
return {
autoload: true,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
return sdk.chat(modelID)
},
options: {
baseURL: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gateway}/compat`,
headers: {
// Cloudflare AI Gateway uses cf-aig-authorization for authenticated gateways
// This enables Unified Billing where Cloudflare handles upstream provider auth
...(apiToken ? { "cf-aig-authorization": `Bearer ${apiToken}` } : {}),
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
},
// Custom fetch to strip Authorization header - AI Gateway uses cf-aig-authorization instead
// Sending Authorization header with invalid value causes auth errors
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers)
headers.delete("Authorization")
return fetch(input, { ...init, headers })
},
},
}
},
cerebras: async () => {
return {
autoload: false,
@@ -12,6 +12,7 @@ import { Storage } from "@/storage/storage"
import { ProviderTransform } from "@/provider/transform"
import { STATUS_CODES } from "http"
import { iife } from "@/util/iife"
import { type SystemError } from "bun"
export namespace MessageV2 {
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
@@ -31,6 +32,7 @@ export namespace MessageV2 {
isRetryable: z.boolean(),
responseHeaders: z.record(z.string(), z.string()).optional(),
responseBody: z.string().optional(),
metadata: z.record(z.string(), z.string()).optional(),
}),
)
export type APIError = z.infer<typeof APIError.Schema>
@@ -609,6 +611,19 @@ export namespace MessageV2 {
},
{ cause: e },
).toObject()
case (e as SystemError)?.code === "ECONNRESET":
return new MessageV2.APIError(
{
message: "Connection reset by server",
isRetryable: true,
metadata: {
code: (e as SystemError).code ?? "",
syscall: (e as SystemError).syscall ?? "",
message: (e as SystemError).message ?? "",
},
},
{ cause: e },
).toObject()
case APICallError.isInstance(e):
const message = iife(() => {
let msg = e.message
-1
View File
@@ -95,7 +95,6 @@ export namespace ToolRegistry {
ReadTool,
GlobTool,
GrepTool,
ListTool,
EditTool,
WriteTool,
TaskTool,
@@ -59,3 +59,53 @@ describe("session.retry.delay", () => {
expect(SessionRetry.delay(1, longError)).toBe(700000)
})
})
describe("session.message-v2.fromError", () => {
test.concurrent(
"converts ECONNRESET socket errors to retryable APIError",
async () => {
using server = Bun.serve({
port: 0,
idleTimeout: 8,
async fetch(req) {
return new Response(
new ReadableStream({
async pull(controller) {
controller.enqueue("Hello,")
await Bun.sleep(10000)
controller.enqueue(" World!")
controller.close()
},
}),
{ headers: { "Content-Type": "text/plain" } },
)
},
})
const error = await fetch(new URL("/", server.url.origin))
.then((res) => res.text())
.catch((e) => e)
const result = MessageV2.fromError(error, { providerID: "test" })
expect(MessageV2.APIError.isInstance(result)).toBe(true)
expect((result as MessageV2.APIError).data.isRetryable).toBe(true)
expect((result as MessageV2.APIError).data.message).toBe("Connection reset by server")
expect((result as MessageV2.APIError).data.metadata?.code).toBe("ECONNRESET")
expect((result as MessageV2.APIError).data.metadata?.message).toInclude("socket connection")
},
15_000,
)
test("ECONNRESET socket error is retryable", () => {
const error = new MessageV2.APIError({
message: "Connection reset by server",
isRetryable: true,
metadata: { code: "ECONNRESET", message: "The socket connection was closed unexpectedly" },
}).toObject() as MessageV2.APIError
const retryable = SessionRetry.retryable(error)
expect(retryable).toBeDefined()
expect(retryable).toBe("Connection reset by server")
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.0.193",
"version": "1.0.202",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.0.193",
"version": "1.0.202",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+15
View File
@@ -131,6 +131,9 @@ export type ApiError = {
[key: string]: string
}
responseBody?: string
metadata?: {
[key: string]: string
}
}
}
@@ -589,6 +592,13 @@ export type EventTuiToastShow = {
}
}
export type EventMcpToolsChanged = {
type: "mcp.tools.changed"
properties: {
server: string
}
}
export type EventCommandExecuted = {
type: "command.executed"
properties: {
@@ -755,6 +765,7 @@ export type Event =
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
| EventMcpToolsChanged
| EventCommandExecuted
| EventSessionCreated
| EventSessionUpdated
@@ -1134,6 +1145,10 @@ export type KeybindsConfig = {
* Toggle terminal title
*/
terminal_title_toggle?: string
/**
* Toggle tips on home screen
*/
tips_toggle?: string
}
export type AgentConfig = {
+36
View File
@@ -5336,6 +5336,15 @@
},
"responseBody": {
"type": "string"
},
"metadata": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
}
},
"required": ["message", "isRetryable"]
@@ -6626,6 +6635,25 @@
},
"required": ["type", "properties"]
},
"Event.mcp.tools.changed": {
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "mcp.tools.changed"
},
"properties": {
"type": "object",
"properties": {
"server": {
"type": "string"
}
},
"required": ["server"]
}
},
"required": ["type", "properties"]
},
"Event.command.executed": {
"type": "object",
"properties": {
@@ -7123,6 +7151,9 @@
{
"$ref": "#/components/schemas/Event.tui.toast.show"
},
{
"$ref": "#/components/schemas/Event.mcp.tools.changed"
},
{
"$ref": "#/components/schemas/Event.command.executed"
},
@@ -7647,6 +7678,11 @@
"description": "Toggle terminal title",
"default": "none",
"type": "string"
},
"tips_toggle": {
"description": "Toggle tips on home screen",
"default": "<leader>h",
"type": "string"
}
},
"additionalProperties": false
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.0.193",
"version": "1.0.202",
"type": "module",
"scripts": {
"dev": "bun run src/index.ts",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.0.193",
"version": "1.0.202",
"type": "module",
"exports": {
"./*": "./src/components/*.tsx",
@@ -44,8 +44,8 @@
"@typescript/native-preview": "catalog:",
"fuzzysort": "catalog:",
"luxon": "catalog:",
"marked": "16.2.0",
"marked-shiki": "1.2.1",
"marked": "catalog:",
"marked-shiki": "catalog:",
"remeda": "catalog:",
"shiki": "catalog:",
"solid-js": "catalog:",
-1
View File
@@ -4,7 +4,6 @@ export const Favicon = () => {
return (
<>
<Link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<Link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<Link rel="shortcut icon" href="/favicon.ico" />
<Link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<Link rel="manifest" href="/site.webmanifest" />
+1
View File
@@ -6,6 +6,7 @@ const icons = {
"arrow-left": `<path d="M8.33464 4.58398L2.91797 10.0007L8.33464 15.4173M3.33464 10.0007H17.0846" stroke="currentColor" stroke-linecap="square"/>`,
archive: `<path d="M16.8747 6.24935H17.3747V5.74935H16.8747V6.24935ZM16.8747 16.8743V17.3743H17.3747V16.8743H16.8747ZM3.12467 16.8743H2.62467V17.3743H3.12467V16.8743ZM3.12467 6.24935V5.74935H2.62467V6.24935H3.12467ZM2.08301 2.91602V2.41602H1.58301V2.91602H2.08301ZM17.9163 2.91602H18.4163V2.41602H17.9163V2.91602ZM17.9163 6.24935V6.74935H18.4163V6.24935H17.9163ZM2.08301 6.24935H1.58301V6.74935H2.08301V6.24935ZM8.33301 9.08268H7.83301V10.0827H8.33301V9.58268V9.08268ZM11.6663 10.0827H12.1663V9.08268H11.6663V9.58268V10.0827ZM16.8747 6.24935H16.3747V16.8743H16.8747H17.3747V6.24935H16.8747ZM16.8747 16.8743V16.3743H3.12467V16.8743V17.3743H16.8747V16.8743ZM3.12467 16.8743H3.62467V6.24935H3.12467H2.62467V16.8743H3.12467ZM3.12467 6.24935V6.74935H16.8747V6.24935V5.74935H3.12467V6.24935ZM2.08301 2.91602V3.41602H17.9163V2.91602V2.41602H2.08301V2.91602ZM17.9163 2.91602H17.4163V6.24935H17.9163H18.4163V2.91602H17.9163ZM17.9163 6.24935V5.74935H2.08301V6.24935V6.74935H17.9163V6.24935ZM2.08301 6.24935H2.58301V2.91602H2.08301H1.58301V6.24935H2.08301ZM8.33301 9.58268V10.0827H11.6663V9.58268V9.08268H8.33301V9.58268Z" fill="currentColor"/>`,
"bubble-5": `<path d="M18.3327 9.99935C18.3327 5.57227 15.0919 2.91602 9.99935 2.91602C4.90676 2.91602 1.66602 5.57227 1.66602 9.99935C1.66602 11.1487 2.45505 13.1006 2.57637 13.3939C2.58707 13.4197 2.59766 13.4434 2.60729 13.4697C2.69121 13.6987 3.04209 14.9354 1.66602 16.7674C3.51787 17.6528 5.48453 16.1973 5.48453 16.1973C6.84518 16.9193 8.46417 17.0827 9.99935 17.0827C15.0919 17.0827 18.3327 14.4264 18.3327 9.99935Z" stroke="currentColor" stroke-linecap="square"/>`,
brain: `<path d="M13.332 8.7487C11.4911 8.7487 9.9987 7.25631 9.9987 5.41536M6.66536 11.2487C8.50631 11.2487 9.9987 12.7411 9.9987 14.582M9.9987 2.78209L9.9987 17.0658M16.004 15.0475C17.1255 14.5876 17.9154 13.4849 17.9154 12.1978C17.9154 11.3363 17.5615 10.5575 16.9913 9.9987C17.5615 9.43991 17.9154 8.66108 17.9154 7.79962C17.9154 6.21199 16.7136 4.90504 15.1702 4.73878C14.7858 3.21216 13.4039 2.08203 11.758 2.08203C11.1171 2.08203 10.5162 2.25337 9.9987 2.55275C9.48117 2.25337 8.88032 2.08203 8.23944 2.08203C6.59353 2.08203 5.21157 3.21216 4.82722 4.73878C3.28377 4.90504 2.08203 6.21199 2.08203 7.79962C2.08203 8.66108 2.43585 9.43991 3.00609 9.9987C2.43585 10.5575 2.08203 11.3363 2.08203 12.1978C2.08203 13.4849 2.87191 14.5876 3.99339 15.0475C4.46688 16.7033 5.9917 17.9154 7.79962 17.9154C8.61335 17.9154 9.36972 17.6698 9.9987 17.2488C10.6277 17.6698 11.384 17.9154 12.1978 17.9154C14.0057 17.9154 15.5305 16.7033 16.004 15.0475Z" stroke="currentColor"/>`,
"bullet-list": `<path d="M9.58329 13.7497H17.0833M9.58329 6.24967H17.0833M6.24996 6.24967C6.24996 7.17015 5.50377 7.91634 4.58329 7.91634C3.66282 7.91634 2.91663 7.17015 2.91663 6.24967C2.91663 5.3292 3.66282 4.58301 4.58329 4.58301C5.50377 4.58301 6.24996 5.3292 6.24996 6.24967ZM6.24996 13.7497C6.24996 14.6701 5.50377 15.4163 4.58329 15.4163C3.66282 15.4163 2.91663 14.6701 2.91663 13.7497C2.91663 12.8292 3.66282 12.083 4.58329 12.083C5.50377 12.083 6.24996 12.8292 6.24996 13.7497Z" stroke="currentColor" stroke-linecap="square"/>`,
"check-small": `<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="currentColor" stroke-linecap="square"/>`,
"chevron-down": `<path d="M6.6665 8.33325L9.99984 11.6666L13.3332 8.33325" stroke="currentColor" stroke-linecap="square"/>`,
+18
View File
@@ -51,6 +51,24 @@
}
}
}
> [data-component="icon-button"] {
background-color: transparent;
&:hover:not(:disabled),
&:focus:not(:disabled),
&:active:not(:disabled) {
background-color: transparent;
}
&:hover:not(:disabled) [data-slot="icon-svg"] {
color: var(--icon-hover);
}
&:active:not(:disabled) [data-slot="icon-svg"] {
color: var(--icon-active);
}
}
}
[data-slot="list-scroll"] {
+1 -7
View File
@@ -1,12 +1,6 @@
import { useMarked } from "../context/marked"
import { ComponentProps, createResource, splitProps } from "solid-js"
function strip(text: string): string {
const wrappedRe = /^\s*<([A-Za-z]\w*)>\s*([\s\S]*?)\s*<\/\1>\s*$/
const match = text.match(wrappedRe)
return match ? match[2] : text
}
export function Markdown(
props: ComponentProps<"div"> & {
text: string
@@ -17,7 +11,7 @@ export function Markdown(
const [local, others] = splitProps(props, ["text", "class", "classList"])
const marked = useMarked()
const [html] = createResource(
() => strip(local.text),
() => local.text,
async (markdown) => {
return marked.parse(markdown)
},
+1 -5
View File
@@ -72,14 +72,10 @@
}
[data-slot="user-message-text"] {
display: -webkit-box;
white-space: pre-wrap;
line-clamp: 3;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
background: var(--surface-inset-base);
padding: 2px 6px;
padding: 6px 12px;
border-radius: 4px;
border: 0.5px solid var(--border-weak-base);
}
+5 -2
View File
@@ -415,8 +415,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
PART_MAPPING["text"] = function TextPartDisplay(props) {
const data = useData()
const part = props.part as TextPart
const content = createMemo(() => (part.text ?? "").trim())
const displayText = createMemo(() => relativizeProjectPaths(content(), data.directory))
const displayText = () => relativizeProjectPaths((part.text ?? "").trim(), data.directory)
return (
<Show when={displayText()}>
@@ -441,6 +440,9 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
ToolRegistry.register({
name: "read",
render(props) {
const args: string[] = []
if (props.input.offset) args.push("offset=" + props.input.offset)
if (props.input.limit) args.push("limit=" + props.input.limit)
return (
<BasicTool
{...props}
@@ -448,6 +450,7 @@ ToolRegistry.register({
trigger={{
title: "Read",
subtitle: props.input.filePath ? getFilename(props.input.filePath) : "",
args,
}}
/>
)
+160
View File
@@ -0,0 +1,160 @@
[data-component="radio-group"] {
display: flex;
flex-direction: column;
gap: calc(var(--spacing) * 2);
[data-slot="radio-group-wrapper"] {
all: unset;
background-color: var(--surface-base);
border-radius: var(--radius-md);
box-shadow: inset 0 0 0 1px var(--border-weak-base);
margin: 0;
padding: 0;
position: relative;
width: fit-content;
}
[data-slot="radio-group-items"] {
display: inline-flex;
list-style: none;
flex-direction: row;
}
[data-slot="radio-group-indicator"] {
background: var(--button-secondary-base);
border-radius: var(--radius-md);
box-shadow:
var(--shadow-xs),
inset 0 0 0 var(--indicator-focus-width, 0px) var(--border-selected),
inset 0 0 0 1px var(--border-base);
content: "";
opacity: var(--indicator-opacity, 1);
position: absolute;
transition:
opacity 300ms ease-in-out,
box-shadow 100ms ease-in-out,
width 150ms ease,
height 150ms ease,
transform 150ms ease;
}
[data-slot="radio-group-item"] {
position: relative;
}
/* Separator between items */
[data-slot="radio-group-item"]:not(:first-of-type)::before {
background: var(--border-weak-base);
border-radius: var(--radius-xs);
content: "";
inset: 6px 0;
position: absolute;
transition: opacity 150ms ease;
width: 1px;
transform: translateX(-0.5px);
}
/* Hide separator when item or previous item is checked */
[data-slot="radio-group-item"]:has([data-slot="radio-group-item-input"][data-checked])::before,
[data-slot="radio-group-item"]:has([data-slot="radio-group-item-input"][data-checked])
+ [data-slot="radio-group-item"]::before {
opacity: 0;
}
[data-slot="radio-group-item-label"] {
color: var(--text-weak);
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
border-radius: var(--radius-md);
cursor: pointer;
display: flex;
flex-wrap: nowrap;
gap: calc(var(--spacing) * 1);
line-height: 1;
padding: 6px 12px;
place-content: center;
position: relative;
transition-duration: 150ms;
transition-property: color, opacity;
transition-timing-function: ease-in-out;
user-select: none;
}
[data-slot="radio-group-item-input"] {
all: unset;
}
/* Checked state */
[data-slot="radio-group-item-input"][data-checked] + [data-slot="radio-group-item-label"] {
color: var(--text-strong);
}
/* Disabled state */
[data-slot="radio-group-item-input"][data-disabled] + [data-slot="radio-group-item-label"] {
cursor: not-allowed;
opacity: 0.5;
}
/* Hover state for unchecked, enabled items */
[data-slot="radio-group-item-input"]:not([data-checked], [data-disabled]) + [data-slot="radio-group-item-label"] {
cursor: pointer;
user-select: none;
}
[data-slot="radio-group-item-input"]:not([data-checked], [data-disabled])
+ [data-slot="radio-group-item-label"]:hover {
color: var(--text-base);
}
[data-slot="radio-group-item-input"]:not([data-checked], [data-disabled])
+ [data-slot="radio-group-item-label"]:active {
opacity: 0.7;
}
/* Focus state */
[data-slot="radio-group-wrapper"]:has([data-slot="radio-group-item-input"]:focus-visible)
[data-slot="radio-group-indicator"] {
--indicator-focus-width: 2px;
}
/* Hide indicator when nothing is checked */
[data-slot="radio-group-wrapper"]:not(:has([data-slot="radio-group-item-input"][data-checked]))
[data-slot="radio-group-indicator"] {
--indicator-opacity: 0;
}
/* Vertical orientation */
&[aria-orientation="vertical"] [data-slot="radio-group-items"] {
flex-direction: column;
}
&[aria-orientation="vertical"] [data-slot="radio-group-item"]:not(:first-of-type)::before {
height: 1px;
width: auto;
inset: 0 6px;
transform: translateY(-0.5px);
}
/* Small size variant */
&[data-size="small"] {
[data-slot="radio-group-item-label"] {
font-size: 12px;
padding: 4px 8px;
}
[data-slot="radio-group-item"]:not(:first-of-type)::before {
inset: 4px 0;
}
&[aria-orientation="vertical"] [data-slot="radio-group-item"]:not(:first-of-type)::before {
inset: 0 4px;
}
}
/* Disabled root state */
&[data-disabled] {
opacity: 0.5;
cursor: not-allowed;
}
}
@@ -0,0 +1,75 @@
import { SegmentedControl as Kobalte } from "@kobalte/core/segmented-control"
import { For, splitProps } from "solid-js"
import type { ComponentProps, JSX } from "solid-js"
export type RadioGroupProps<T> = Omit<
ComponentProps<typeof Kobalte>,
"value" | "defaultValue" | "onChange" | "children"
> & {
options: T[]
current?: T
defaultValue?: T
value?: (x: T) => string
label?: (x: T) => JSX.Element | string
onSelect?: (value: T | undefined) => void
class?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"]
size?: "small" | "medium"
}
export function RadioGroup<T>(props: RadioGroupProps<T>) {
const [local, others] = splitProps(props, [
"class",
"classList",
"options",
"current",
"defaultValue",
"value",
"label",
"onSelect",
"size",
])
const getValue = (item: T): string => {
if (local.value) return local.value(item)
return String(item)
}
const getLabel = (item: T): JSX.Element | string => {
if (local.label) return local.label(item)
return String(item)
}
const findOption = (v: string): T | undefined => {
return local.options.find((opt) => getValue(opt) === v)
}
return (
<Kobalte
{...others}
data-component="radio-group"
data-size={local.size ?? "medium"}
classList={{
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
}}
value={local.current ? getValue(local.current) : undefined}
defaultValue={local.defaultValue ? getValue(local.defaultValue) : undefined}
onChange={(v) => local.onSelect?.(findOption(v))}
>
<div role="presentation" data-slot="radio-group-wrapper">
<Kobalte.Indicator data-slot="radio-group-indicator" />
<div role="presentation" data-slot="radio-group-items">
<For each={local.options}>
{(option) => (
<Kobalte.Item value={getValue(option)} data-slot="radio-group-item">
<Kobalte.ItemInput data-slot="radio-group-item-input" />
<Kobalte.ItemLabel data-slot="radio-group-item-label">{getLabel(option)}</Kobalte.ItemLabel>
</Kobalte.Item>
)}
</For>
</div>
</div>
</Kobalte>
)
}
+1 -1
View File
@@ -26,7 +26,7 @@
align-items: flex-start;
align-self: stretch;
min-width: 0;
gap: 42px;
gap: 28px;
overflow-anchor: none;
}
+185 -140
View File
@@ -60,6 +60,31 @@ function computeStatusFromPart(part: PartType | undefined): string | undefined {
return undefined
}
function AssistantMessageItem(props: {
message: AssistantMessage
summary: string | undefined
response: string | undefined
lastTextPartId: string | undefined
working: boolean
}) {
const data = useData()
const msgParts = createMemo(() => data.store.part[props.message.id] ?? [])
const lastTextPart = createMemo(() =>
msgParts()
.filter((p) => p?.type === "text")
.at(-1),
)
const filteredParts = createMemo(() => {
if (!props.working && !props.summary && props.response && props.lastTextPartId === lastTextPart()?.id) {
return msgParts().filter((p) => p?.id !== lastTextPart()?.id)
}
return msgParts()
})
return <Message message={props.message} parts={filteredParts()} />
}
export function SessionTurn(
props: ParentProps<{
sessionID: string
@@ -77,125 +102,119 @@ export function SessionTurn(
const data = useData()
const diffComponent = useDiffComponent()
const derived = createMemo(() => {
const allMessages = data.store.message[props.sessionID] ?? []
const userMessages = allMessages.filter((m) => m.role === "user").sort((a, b) => a.id.localeCompare(b.id))
const lastUserMessage = userMessages.at(-1)
const message = userMessages.find((m) => m.id === props.messageID)
const allMessages = createMemo(() => data.store.message[props.sessionID] ?? [])
const userMessages = createMemo(() =>
allMessages()
.filter((m) => m.role === "user")
.sort((a, b) => a.id.localeCompare(b.id)),
)
if (!message) {
return {
message: undefined,
parts: [] as PartType[],
assistantMessages: [] as AssistantMessage[],
assistantParts: [] as PartType[],
lastAssistantMessage: undefined as AssistantMessage | undefined,
lastTextPart: undefined as PartType | undefined,
error: undefined,
hasSteps: false,
isShellMode: false,
rawStatus: undefined as string | undefined,
isLastUserMessage: false,
}
}
const message = createMemo(() => userMessages().find((m) => m.id === props.messageID))
const isLastUserMessage = createMemo(() => message()?.id === userMessages().at(-1)?.id)
const parts = data.store.part[message.id] ?? []
const assistantMessages = allMessages.filter(
(m) => m.role === "assistant" && m.parentID === message.id,
) as AssistantMessage[]
const assistantParts: PartType[] = []
for (const m of assistantMessages) {
const msgParts = data.store.part[m.id]
if (msgParts) {
for (const p of msgParts) {
if (p) assistantParts.push(p)
}
}
}
const lastAssistantMessage = assistantMessages.at(-1)
const error = assistantMessages.find((m) => m.error)?.error
let lastTextPart: PartType | undefined
for (let i = assistantParts.length - 1; i >= 0; i--) {
if (assistantParts[i]?.type === "text") {
lastTextPart = assistantParts[i]
break
}
}
const hasSteps = assistantParts.some((p) => p?.type === "tool")
let isShellMode = false
if (parts.every((p) => p?.type === "text" && p?.synthetic) && assistantParts.length === 1) {
const assistantPart = assistantParts[0]
if (assistantPart?.type === "tool" && assistantPart?.tool === "bash") {
isShellMode = true
}
}
let resolvedParts = assistantParts
const currentTask = assistantParts.findLast(
(p) =>
p &&
p.type === "tool" &&
p.tool === "task" &&
p.state &&
"metadata" in p.state &&
p.state.metadata &&
p.state.metadata.sessionId &&
p.state.status === "running",
) as ToolPart | undefined
if (currentTask?.state && "metadata" in currentTask.state && currentTask.state.metadata?.sessionId) {
const taskMessages = data.store.message[currentTask.state.metadata.sessionId as string]?.filter(
(m) => m.role === "assistant",
)
if (taskMessages) {
const taskParts: PartType[] = []
for (const m of taskMessages) {
const msgParts = data.store.part[m.id]
if (msgParts) {
for (const p of msgParts) {
if (p) taskParts.push(p)
}
}
}
if (taskParts.length > 0) {
resolvedParts = taskParts
}
}
}
const lastPart = resolvedParts.at(-1)
const rawStatus = computeStatusFromPart(lastPart)
return {
message,
parts,
assistantMessages,
assistantParts,
lastAssistantMessage,
lastTextPart,
error,
hasSteps,
isShellMode,
rawStatus,
isLastUserMessage: message.id === lastUserMessage?.id,
}
const parts = createMemo(() => {
const msg = message()
if (!msg) return []
return data.store.part[msg.id] ?? []
})
const message = () => derived().message
const parts = () => derived().parts
const assistantMessages = () => derived().assistantMessages
const assistantParts = () => derived().assistantParts
const lastAssistantMessage = () => derived().lastAssistantMessage
const lastTextPart = () => derived().lastTextPart
const error = () => derived().error
const hasSteps = () => derived().hasSteps
const isShellMode = () => derived().isShellMode
const rawStatus = () => derived().rawStatus
const assistantMessages = createMemo(() => {
const msg = message()
if (!msg) return [] as AssistantMessage[]
return allMessages().filter((m) => m.role === "assistant" && m.parentID === msg.id) as AssistantMessage[]
})
const lastAssistantMessage = createMemo(() => assistantMessages().at(-1))
const error = createMemo(() => assistantMessages().find((m) => m.error)?.error)
const lastTextPart = createMemo(() => {
const msgs = assistantMessages()
for (let mi = msgs.length - 1; mi >= 0; mi--) {
const msgParts = data.store.part[msgs[mi].id] ?? []
for (let pi = msgParts.length - 1; pi >= 0; pi--) {
const part = msgParts[pi]
if (part?.type === "text") return part as TextPart
}
}
return undefined
})
const hasSteps = createMemo(() => {
for (const m of assistantMessages()) {
const msgParts = data.store.part[m.id]
if (!msgParts) continue
for (const p of msgParts) {
if (p?.type === "tool") return true
}
}
return false
})
const shellModePart = createMemo(() => {
const p = parts()
if (!p.every((part) => part?.type === "text" && part?.synthetic)) return
const msgs = assistantMessages()
if (msgs.length !== 1) return
const msgParts = data.store.part[msgs[0].id] ?? []
if (msgParts.length !== 1) return
const assistantPart = msgParts[0]
if (assistantPart?.type === "tool" && assistantPart.tool === "bash") return assistantPart
})
const isShellMode = createMemo(() => !!shellModePart())
const rawStatus = createMemo(() => {
const msgs = assistantMessages()
let last: PartType | undefined
let currentTask: ToolPart | undefined
for (let mi = msgs.length - 1; mi >= 0; mi--) {
const msgParts = data.store.part[msgs[mi].id] ?? []
for (let pi = msgParts.length - 1; pi >= 0; pi--) {
const part = msgParts[pi]
if (!part) continue
if (!last) last = part
if (
part.type === "tool" &&
part.tool === "task" &&
part.state &&
"metadata" in part.state &&
part.state.metadata?.sessionId &&
part.state.status === "running"
) {
currentTask = part as ToolPart
break
}
}
if (currentTask) break
}
const taskSessionId =
currentTask?.state && "metadata" in currentTask.state
? (currentTask.state.metadata?.sessionId as string | undefined)
: undefined
if (taskSessionId) {
const taskMessages = data.store.message[taskSessionId] ?? []
for (let mi = taskMessages.length - 1; mi >= 0; mi--) {
const msg = taskMessages[mi]
if (!msg || msg.role !== "assistant") continue
const msgParts = data.store.part[msg.id] ?? []
for (let pi = msgParts.length - 1; pi >= 0; pi--) {
const part = msgParts[pi]
if (part) return computeStatusFromPart(part)
}
}
}
return computeStatusFromPart(last)
})
const status = createMemo(
() =>
@@ -203,7 +222,7 @@ export function SessionTurn(
type: "idle",
},
)
const working = createMemo(() => status().type !== "idle" && derived().isLastUserMessage)
const working = createMemo(() => status().type !== "idle" && isLastUserMessage())
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
@@ -246,6 +265,7 @@ export function SessionTurn(
retrySeconds: 0,
status: rawStatus(),
duration: duration(),
summaryWaitTimedOut: false,
})
createEffect(() => {
@@ -286,6 +306,42 @@ export function SessionTurn(
onCleanup(() => clearInterval(timer))
})
createEffect(() => {
if (working()) {
setStore("summaryWaitTimedOut", false)
}
})
createEffect(() => {
if (working() || !isLastUserMessage()) return
const diffs = message()?.summary?.diffs
if (!diffs?.length) return
if (summary()) return
if (store.summaryWaitTimedOut) return
const timer = setTimeout(() => {
setStore("summaryWaitTimedOut", true)
}, 6000)
onCleanup(() => clearTimeout(timer))
})
const waitingForSummary = createMemo(() => {
if (!isLastUserMessage()) return false
if (working()) return false
const diffs = message()?.summary?.diffs
if (!diffs?.length) return false
if (summary()) return false
return !store.summaryWaitTimedOut
})
const showSummarySection = createMemo(() => {
if (working()) return false
return !waitingForSummary()
})
let lastStatusChange = Date.now()
let statusTimeout: number | undefined
createEffect(() => {
@@ -330,7 +386,7 @@ export function SessionTurn(
>
<Switch>
<Match when={isShellMode()}>
<Part part={assistantParts()[0]} message={msg()} defaultOpen />
<Part part={shellModePart()!} message={msg()} defaultOpen />
</Match>
<Match when={true}>
{/* Title (sticky) */}
@@ -362,7 +418,7 @@ export function SessionTurn(
size="small"
onClick={props.onStepsExpandedToggle ?? (() => {})}
>
<Show when={working()}>
<Show when={working() || waitingForSummary()}>
<Spinner />
</Show>
<Switch>
@@ -379,6 +435,7 @@ export function SessionTurn(
</span>
<span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span>
</Match>
<Match when={waitingForSummary()}>Generating summary</Match>
<Match when={working()}>{store.status ?? "Considering next steps"}</Match>
<Match when={props.stepsExpanded}>Hide steps</Match>
<Match when={!props.stepsExpanded}>Show steps</Match>
@@ -395,27 +452,15 @@ export function SessionTurn(
<Show when={props.stepsExpanded && assistantMessages().length > 0}>
<div data-slot="session-turn-collapsible-content-inner">
<For each={assistantMessages()}>
{(assistantMessage) => {
const parts = createMemo(() => data.store.part[assistantMessage.id] ?? [])
const last = createMemo(() =>
parts()
.filter((p) => p?.type === "text")
.at(-1),
)
return (
<Switch>
<Match when={!summary() && response() && lastTextPart()?.id === last()?.id}>
<Message
message={assistantMessage}
parts={parts().filter((p) => p?.id !== last()?.id)}
/>
</Match>
<Match when={true}>
<Message message={assistantMessage} parts={parts()} />
</Match>
</Switch>
)
}}
{(assistantMessage) => (
<AssistantMessageItem
message={assistantMessage}
summary={summary()}
response={response()}
lastTextPartId={lastTextPart()?.id}
working={working()}
/>
)}
</For>
<Show when={error()}>
<Card variant="error" class="error-card">
@@ -425,7 +470,7 @@ export function SessionTurn(
</div>
</Show>
{/* Summary */}
<Show when={!working()}>
<Show when={showSummarySection()}>
<div data-slot="session-turn-summary-section">
<div data-slot="session-turn-summary-header">
<Switch>
+104 -73
View File
@@ -1,4 +1,4 @@
import { batch, createEffect } from "solid-js"
import { createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
@@ -11,81 +11,62 @@ export function createAutoScroll(options: AutoScrollOptions) {
let scrollRef: HTMLElement | undefined
const [store, setStore] = createStore({
contentRef: undefined as HTMLElement | undefined,
lastScrollTop: 0,
lastScrollHeight: 0,
lastContentWidth: 0,
autoScrolled: false,
userScrolled: false,
reflowing: false,
})
let lastScrollTop = 0
let isAutoScrolling = false
let autoScrollTimeout: ReturnType<typeof setTimeout> | undefined
let isMouseDown = false
let cleanupListeners: (() => void) | undefined
function scrollToBottom() {
if (!scrollRef || store.userScrolled || !options.working()) return
setStore("autoScrolled", true)
requestAnimationFrame(() => {
scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "smooth" })
requestAnimationFrame(() => {
batch(() => {
setStore("lastScrollTop", scrollRef?.scrollTop ?? 0)
setStore("lastScrollHeight", scrollRef?.scrollHeight ?? 0)
setStore("autoScrolled", false)
})
})
isAutoScrolling = true
if (autoScrollTimeout) clearTimeout(autoScrollTimeout)
autoScrollTimeout = setTimeout(() => {
isAutoScrolling = false
}, 1000)
scrollRef.scrollTo({
top: scrollRef.scrollHeight,
behavior: "smooth",
})
}
function handleScroll() {
if (!scrollRef || store.autoScrolled) return
if (!scrollRef) return
const scrollTop = scrollRef.scrollTop
const scrollHeight = scrollRef.scrollHeight
const { scrollTop, scrollHeight, clientHeight } = scrollRef
const atBottom = Math.abs(scrollHeight - clientHeight - scrollTop) < 10
if (store.reflowing) {
batch(() => {
setStore("lastScrollTop", scrollTop)
setStore("lastScrollHeight", scrollHeight)
})
if (isAutoScrolling) {
if (atBottom) {
isAutoScrolling = false
if (autoScrollTimeout) clearTimeout(autoScrollTimeout)
}
lastScrollTop = scrollTop
return
}
const scrollHeightChanged = Math.abs(scrollHeight - store.lastScrollHeight) > 10
const scrollTopDelta = scrollTop - store.lastScrollTop
if (atBottom) {
if (store.userScrolled) {
setStore("userScrolled", false)
}
lastScrollTop = scrollTop
return
}
// Handle reflow-caused scroll position changes
if (scrollHeightChanged && scrollTopDelta < 0) {
const heightRatio = store.lastScrollHeight > 0 ? scrollHeight / store.lastScrollHeight : 1
const expectedScrollTop = store.lastScrollTop * heightRatio
if (Math.abs(scrollTop - expectedScrollTop) < 100) {
batch(() => {
setStore("lastScrollTop", scrollTop)
setStore("lastScrollHeight", scrollHeight)
})
return
const delta = scrollTop - lastScrollTop
if (delta < 0) {
if (isMouseDown && !store.userScrolled && options.working()) {
setStore("userScrolled", true)
options.onUserInteracted?.()
}
}
// Handle reset to top while working
const reset = scrollTop <= 0 && store.lastScrollTop > 0 && options.working() && !store.userScrolled
if (reset) {
batch(() => {
setStore("lastScrollTop", scrollTop)
setStore("lastScrollHeight", scrollHeight)
})
requestAnimationFrame(scrollToBottom)
return
}
// Detect intentional scroll up
const scrolledUp = scrollTop < store.lastScrollTop - 50 && !scrollHeightChanged
if (scrolledUp && options.working()) {
setStore("userScrolled", true)
options.onUserInteracted?.()
}
batch(() => {
setStore("lastScrollTop", scrollTop)
setStore("lastScrollHeight", scrollHeight)
})
lastScrollTop = scrollTop
}
function handleInteraction() {
@@ -95,36 +76,86 @@ export function createAutoScroll(options: AutoScrollOptions) {
}
}
function handleWheel(e: WheelEvent) {
if (e.deltaY < 0 && !store.userScrolled && options.working()) {
setStore("userScrolled", true)
options.onUserInteracted?.()
}
}
function handleTouchStart() {
if (!store.userScrolled && options.working()) {
setStore("userScrolled", true)
options.onUserInteracted?.()
}
}
function handleKeyDown(e: KeyboardEvent) {
if (["ArrowUp", "PageUp", "Home"].includes(e.key)) {
if (!store.userScrolled && options.working()) {
setStore("userScrolled", true)
options.onUserInteracted?.()
}
}
}
function handleMouseDown() {
isMouseDown = true
window.addEventListener("mouseup", handleMouseUp)
}
function handleMouseUp() {
isMouseDown = false
window.removeEventListener("mouseup", handleMouseUp)
}
// Reset userScrolled when work completes
createEffect(() => {
if (!options.working()) setStore("userScrolled", false)
if (!options.working()) {
setStore("userScrolled", false)
}
})
// Handle content resize
createResizeObserver(
() => store.contentRef,
({ width }) => {
const widthChanged = Math.abs(width - store.lastContentWidth) > 5
if (widthChanged && store.lastContentWidth > 0) {
setStore("reflowing", true)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setStore("reflowing", false)
if (options.working() && !store.userScrolled) {
scrollToBottom()
}
})
})
} else if (!store.reflowing) {
() => {
if (options.working() && !store.userScrolled) {
scrollToBottom()
}
setStore("lastContentWidth", width)
},
)
onCleanup(() => {
if (autoScrollTimeout) clearTimeout(autoScrollTimeout)
if (cleanupListeners) cleanupListeners()
})
return {
scrollRef: (el: HTMLElement | undefined) => {
if (cleanupListeners) {
cleanupListeners()
cleanupListeners = undefined
}
scrollRef = el
if (el) {
lastScrollTop = el.scrollTop
el.style.overflowAnchor = "none"
el.addEventListener("wheel", handleWheel, { passive: true })
el.addEventListener("touchstart", handleTouchStart, { passive: true })
el.addEventListener("keydown", handleKeyDown)
el.addEventListener("mousedown", handleMouseDown)
cleanupListeners = () => {
el.removeEventListener("wheel", handleWheel)
el.removeEventListener("touchstart", handleTouchStart)
el.removeEventListener("keydown", handleKeyDown)
el.removeEventListener("mousedown", handleMouseDown)
window.removeEventListener("mouseup", handleMouseUp)
}
}
},
contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
handleScroll,
+1
View File
@@ -29,6 +29,7 @@
@import "../components/message-nav.css" layer(components);
@import "../components/popover.css" layer(components);
@import "../components/progress-circle.css" layer(components);
@import "../components/radio-group.css" layer(components);
@import "../components/resize-handle.css" layer(components);
@import "../components/select.css" layer(components);
@import "../components/spinner.css" layer(components);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.0.193",
"version": "1.0.202",
"private": true,
"type": "module",
"exports": {
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/web",
"type": "module",
"version": "1.0.193",
"version": "1.0.202",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
@@ -24,8 +24,8 @@
"js-base64": "3.7.7",
"lang-map": "0.4.0",
"luxon": "catalog:",
"marked": "15.0.12",
"marked-shiki": "1.2.1",
"marked": "catalog:",
"marked-shiki": "catalog:",
"rehype-autolink-headings": "7.1.0",
"remeda": "catalog:",
"shiki": "catalog:",
+1 -1
View File
@@ -42,7 +42,7 @@ OpenCode comes with two built-in subagents, **General** and **Explore**. We'll l
## Built-in
OpenCode comes with two built-in primary agents and one built-in subagent.
OpenCode comes with two built-in primary agents and two built-in subagents.
---
+225
View File
@@ -69,6 +69,16 @@ This command will guide you through creating a new agent with a custom system pr
---
#### list
List all available agents.
```bash
opencode agent list
```
---
### auth
Command to manage credentials and login for providers.
@@ -156,6 +166,88 @@ opencode github run
---
### mcp
Manage Model Context Protocol servers.
```bash
opencode mcp [command]
```
---
#### add
Add an MCP server to your configuration.
```bash
opencode mcp add
```
This command will guide you through adding either a local or remote MCP server.
---
#### list
List all configured MCP servers and their connection status.
```bash
opencode mcp list
```
Or use the short version.
```bash
opencode mcp ls
```
---
#### auth
Authenticate with an OAuth-enabled MCP server.
```bash
opencode mcp auth [name]
```
If you don't provide a server name, you'll be prompted to select from available OAuth-capable servers.
You can also list OAuth-capable servers and their authentication status.
```bash
opencode mcp auth list
```
Or use the short version.
```bash
opencode mcp auth ls
```
---
#### logout
Remove OAuth credentials for an MCP server.
```bash
opencode mcp logout [name]
```
---
#### debug
Debug OAuth connection issues for an MCP server.
```bash
opencode mcp debug <name>
```
---
### models
List all available models from configured providers.
@@ -250,6 +342,138 @@ This starts an HTTP server that provides API access to opencode functionality wi
---
### session
Manage OpenCode sessions.
```bash
opencode session [command]
```
---
#### list
List all OpenCode sessions.
```bash
opencode session list
```
##### Flags
| Flag | Short | Description |
| ------------- | ----- | ------------------------------------ |
| `--max-count` | `-n` | Limit to N most recent sessions |
| `--format` | | Output format: table or json (table) |
---
### stats
Show token usage and cost statistics for your OpenCode sessions.
```bash
opencode stats
```
#### Flags
| Flag | Description |
| ----------- | --------------------------------------------------------------- |
| `--days` | Show stats for the last N days (all time) |
| `--tools` | Number of tools to show (all) |
| `--project` | Filter by project (all projects, empty string: current project) |
---
### export
Export session data as JSON.
```bash
opencode export [sessionID]
```
If you don't provide a session ID, you'll be prompted to select from available sessions.
---
### import
Import session data from a JSON file or OpenCode share URL.
```bash
opencode import <file>
```
You can import from a local file or an OpenCode share URL.
```bash
opencode import session.json
opencode import https://opncd.ai/s/abc123
```
---
### web
Start a headless OpenCode server with a web interface.
```bash
opencode web
```
This starts an HTTP server and opens a web browser to access OpenCode through a web interface.
#### Flags
| Flag | Short | Description |
| ------------ | ----- | --------------------- |
| `--port` | `-p` | Port to listen on |
| `--hostname` | | Hostname to listen on |
---
### acp
Start an ACP (Agent Client Protocol) server.
```bash
opencode acp
```
This command starts an ACP server that communicates via stdin/stdout using nd-JSON.
#### Flags
| Flag | Description |
| ------------ | --------------------- |
| `--cwd` | Working directory |
| `--port` | Port to listen on |
| `--hostname` | Hostname to listen on |
---
### uninstall
Uninstall OpenCode and remove all related files.
```bash
opencode uninstall
```
#### Flags
| Flag | Short | Description |
| --------------- | ----- | ------------------------------------------- |
| `--keep-config` | `-c` | Keep configuration files |
| `--keep-data` | `-d` | Keep session data and snapshots |
| `--dry-run` | | Show what would be removed without removing |
| `--force` | `-f` | Skip confirmation prompts |
---
### upgrade
Updates opencode to the latest version or a specific version.
@@ -329,3 +553,4 @@ These environment variables enable experimental features that may change or be r
| `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX` | number | Max output tokens for LLM responses |
| `OPENCODE_EXPERIMENTAL_FILEWATCHER` | boolean | Enable file watcher for entire dir |
| `OPENCODE_EXPERIMENTAL_OXFMT` | boolean | Enable oxfmt formatter |
| `OPENCODE_EXPERIMENTAL_LSP_TOOL` | boolean | Enable experimental LSP tool |
+16 -6
View File
@@ -28,11 +28,13 @@ OpenCode supports both **JSON** and **JSONC** (JSON with Comments) formats.
You can place your config in a couple of different locations and they have a
different order of precedence.
:::note[Config Merging]
Configuration files are **merged together**, not replaced. Settings from all config locations are combined using a deep merge strategy, where later configs override earlier ones only for conflicting keys. Non-conflicting settings from all configs are preserved.
:::note
Configuration files are **merged together**, not replaced.
:::
Configuration files are merged together, not replaced. Settings from the following config locations are combined. Where later configs override earlier ones only for conflicting keys. Non-conflicting settings from all configs are preserved.
For example, if your global config sets `theme: "opencode"` and `autoupdate: true`, and your project config sets `model: "anthropic/claude-sonnet-4-5"`, the final configuration will include all three settings.
:::
---
@@ -58,13 +60,15 @@ This is also safe to be checked into Git and uses the same schema as the global
### Custom path
You can also specify a custom config file path using the `OPENCODE_CONFIG` environment variable. Settings from this config are merged with and can override the global and project configs.
You can also specify a custom config file path using the `OPENCODE_CONFIG` environment variable.
```bash
export OPENCODE_CONFIG=/path/to/my/custom-config.json
opencode run "Hello world"
```
Settings from this config are merged with and **can override** the global and project configs.
---
### Custom directory
@@ -79,7 +83,7 @@ export OPENCODE_CONFIG_DIR=/path/to/my/config-directory
opencode run "Hello world"
```
Note: The custom directory is loaded after the global config and `.opencode` directories, so it can override their settings.
The custom directory is loaded after the global config and `.opencode` directories, so it **can override** their settings.
---
@@ -376,6 +380,10 @@ You can disable providers that are loaded automatically through the `disabled_pr
}
```
:::note
The `disabled_providers` takes priority over `enabled_providers`.
:::
The `disabled_providers` option accepts an array of provider IDs. When a provider is disabled:
- It won't be loaded even if environment variables are set.
@@ -398,9 +406,11 @@ You can specify an allowlist of providers through the `enabled_providers` option
This is useful when you want to restrict OpenCode to only use specific providers rather than disabling them one by one.
:::note
If a provider appears in both `enabled_providers` and `disabled_providers`, the `disabled_providers` takes priority for backwards compatibility.
The `disabled_providers` takes priority over `enabled_providers`.
:::
If a provider appears in both `enabled_providers` and `disabled_providers`, the `disabled_providers` takes priority for backwards compatibility.
---
## Variables
+18 -17
View File
@@ -15,23 +15,24 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw
## Plugins
| Name | Description |
| ------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping |
| [opencode-skills](https://github.com/malhashemi/opencode-skills) | Manage and organize OpenCode skills and capabilities |
| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools |
| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits |
| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing |
| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | Use Antigravity's free models instead of API billing |
| [opencode-google-antigravity-auth](https://github.com/shekohex/opencode-google-antigravity-auth) | Google Antigravity OAuth Plugin, with support for Google Search, and more robust API handling |
| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs |
| [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git) | Add native websearch support for supported providers with Google grounded style |
| [opencode-pty](https://github.com/shekohex/opencode-pty.git) | Enables AI agents to run background processes in a PTY, send interactive input to them. |
| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime |
| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs |
| [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply) | 10x faster code editing with Morph Fast Apply API and lazy edit markers |
| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible |
| [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer) | AI-powered automatic Zellij session naming based on OpenCode context |
| Name | Description |
| ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping |
| [opencode-skills](https://github.com/malhashemi/opencode-skills) | Manage and organize OpenCode skills and capabilities |
| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools |
| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits |
| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing |
| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | Use Antigravity's free models instead of API billing |
| [opencode-google-antigravity-auth](https://github.com/shekohex/opencode-google-antigravity-auth) | Google Antigravity OAuth Plugin, with support for Google Search, and more robust API handling |
| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs |
| [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git) | Add native websearch support for supported providers with Google grounded style |
| [opencode-pty](https://github.com/shekohex/opencode-pty.git) | Enables AI agents to run background processes in a PTY, send interactive input to them. |
| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy) | Instructions for non-interactive shell commands - prevents hangs from TTY-dependent operations |
| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime |
| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs |
| [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply) | 10x faster code editing with Morph Fast Apply API and lazy edit markers |
| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible |
| [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer) | AI-powered automatic Zellij session naming based on OpenCode context |
---
+1 -1
View File
@@ -32,7 +32,7 @@ Here we are using a community-created CI/CD component for OpenCode — [nagyv/gi
```yaml title=".gitlab-ci.yml"
include:
- component: $CI_SERVER_FQDN/nagyv/gitlab-opencode/opencode@1.0.0
- component: $CI_SERVER_FQDN/nagyv/gitlab-opencode/opencode@2
inputs:
config_dir: ${CI_PROJECT_DIR}/opencode-config
auth_json: $OPENCODE_AUTH_JSON # The variable name for your OpenCode authentication JSON
+3 -2
View File
@@ -24,8 +24,9 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf
"session_unshare": "none",
"session_interrupt": "escape",
"session_compact": "<leader>c",
"session_child_cycle": "<leader>+right",
"session_child_cycle_reverse": "<leader>+left",
"session_child_cycle": "<leader>right",
"session_child_cycle_reverse": "<leader>left",
"session_parent": "<leader>up",
"messages_page_up": "pageup",
"messages_page_down": "pagedown",
"messages_half_page_up": "ctrl+alt+u",
+108 -55
View File
@@ -3,37 +3,27 @@ title: MCP servers
description: Add local and remote MCP tools.
---
You can add external tools to OpenCode using the _Model Context Protocol_, or MCP.
OpenCode supports both:
- Local servers
- Remote servers
You can add external tools to OpenCode using the _Model Context Protocol_, or MCP. OpenCode supports both local and remote servers.
Once added, MCP tools are automatically available to the LLM alongside built-in tools.
---
## Caveats
#### Caveats
When you use an MCP server, it adds to the context. This can quickly add up if
you have a lot of tools. So we recommend being careful with which MCP servers
you use.
When you use an MCP server, it adds to the context. This can quickly add up if you have a lot of tools. So we recommend being careful with which MCP servers you use.
:::tip
MCP servers add to your context, so you want to be careful with which
ones you enable.
MCP servers add to your context, so you want to be careful with which ones you enable.
:::
Certain MCP servers, like the GitHub MCP server tend to add a lot of tokens and
can easily exceed the context limit.
Certain MCP servers, like the GitHub MCP server, tend to add a lot of tokens and can easily exceed the context limit.
---
## Configure
## Enable
You can define MCP servers in your OpenCode config under `mcp`. Add each MCP
with a unique name. You can refer to that MCP by name when prompting the LLM.
You can define MCP servers in your OpenCode config under `mcp`. Add each MCP with a unique name. You can refer to that MCP by name when prompting the LLM.
```jsonc title="opencode.jsonc" {6}
{
@@ -54,7 +44,7 @@ You can also disable a server by setting `enabled` to `false`. This is useful if
---
### Local
## Local
Add local MCP servers using `type` to `"local"` within the MCP object.
@@ -77,8 +67,7 @@ Add local MCP servers using `type` to `"local"` within the MCP object.
The command is how the local MCP server is started. You can also pass in a list of environment variables as well.
For example, here's how I can add the test
[`@modelcontextprotocol/server-everything`](https://www.npmjs.com/package/@modelcontextprotocol/server-everything) MCP server.
For example, here's how you can add the test [`@modelcontextprotocol/server-everything`](https://www.npmjs.com/package/@modelcontextprotocol/server-everything) MCP server.
```jsonc title="opencode.jsonc"
{
@@ -98,6 +87,8 @@ And to use it I can add `use the mcp_everything tool` to my prompts.
use the mcp_everything tool to add the number 3 and 4
```
---
#### Options
Here are all the options for configuring a local MCP server.
@@ -112,9 +103,9 @@ Here are all the options for configuring a local MCP server.
---
### Remote
## Remote
Add remote MCP servers under by setting `type` to `"remote"`.
Add remote MCP servers by setting `type` to `"remote"`.
```json title="opencode.json"
{
@@ -132,7 +123,9 @@ Add remote MCP servers under by setting `type` to `"remote"`.
}
```
Here the `url` is the URL of the remote MCP server and with the `headers` option you can pass in a list of headers.
The `url` is the URL of the remote MCP server and with the `headers` option you can pass in a list of headers.
---
#### Options
@@ -147,7 +140,7 @@ Here the `url` is the URL of the remote MCP server and with the `headers` option
---
### OAuth
## OAuth
OpenCode automatically handles OAuth authentication for remote MCP servers. When a server requires authentication, OpenCode will:
@@ -155,7 +148,9 @@ OpenCode automatically handles OAuth authentication for remote MCP servers. When
2. Use **Dynamic Client Registration (RFC 7591)** if supported by the server
3. Store tokens securely for future requests
#### Automatic OAuth
---
### Automatic
For most OAuth-enabled MCP servers, no special configuration is needed. Just configure the remote server:
@@ -171,13 +166,15 @@ For most OAuth-enabled MCP servers, no special configuration is needed. Just con
}
```
If the server requires authentication, OpenCode will prompt you to authenticate when you first try to use it.
If the server requires authentication, OpenCode will prompt you to authenticate when you first try to use it. If not, you can [manually trigger the flow](#authenticating) with `opencode mcp auth <server-name>`.
#### Pre-registered Client
---
### Pre-registered
If you have client credentials from the MCP server provider, you can configure them:
```json title="opencode.json"
```json title="opencode.json" {7-11}
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
@@ -194,11 +191,39 @@ If you have client credentials from the MCP server provider, you can configure t
}
```
---
### Authenticating
You can manually trigger authentication or manage credentials.
Authenticate with a specific MCP server:
```bash
opencode mcp auth my-oauth-server
```
List all MCP servers and their auth status:
```bash
opencode mcp list
```
Remove stored credentials:
```bash
opencode mcp logout my-oauth-server
```
The `mcp auth` command will open your browser for authorization. After you authorize, OpenCode will store the tokens securely in `~/.local/share/opencode/mcp-auth.json`.
---
#### Disabling OAuth
If you want to disable automatic OAuth for a server (e.g., for servers that use API keys instead), set `oauth` to `false`:
```json title="opencode.json"
```json title="opencode.json" {7}
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
@@ -214,38 +239,36 @@ If you want to disable automatic OAuth for a server (e.g., for servers that use
}
```
---
#### OAuth Options
| Option | Type | Required | Description |
| -------------- | --------------- | -------- | -------------------------------------------------------------------------------- |
| `oauth` | Object \| false | | OAuth config object, or `false` to disable OAuth auto-detection. |
| `clientId` | String | | OAuth client ID. If not provided, dynamic client registration will be attempted. |
| `clientSecret` | String | | OAuth client secret, if required by the authorization server. |
| `scope` | String | | OAuth scopes to request during authorization. |
| Option | Type | Description |
| -------------- | --------------- | -------------------------------------------------------------------------------- |
| `oauth` | Object \| false | OAuth config object, or `false` to disable OAuth auto-detection. |
| `clientId` | String | OAuth client ID. If not provided, dynamic client registration will be attempted. |
| `clientSecret` | String | OAuth client secret, if required by the authorization server. |
| `scope` | String | OAuth scopes to request during authorization. |
#### Authenticating
#### Debugging
You can manually trigger authentication or manage credentials:
If a remote MCP server is failing to authenticate, you can diagnose issues with:
```bash
# Authenticate with a specific MCP server
opencode mcp auth my-oauth-server
# View auth status for all OAuth-capable servers
opencode mcp auth list
# List all MCP servers and their auth status
opencode mcp list
# Remove stored credentials
opencode mcp logout my-oauth-server
# Debug connection and OAuth flow for a specific server
opencode mcp debug my-oauth-server
```
The `mcp auth` command will open your browser for authorization. After you authorize, OpenCode will store the tokens securely in `~/.local/share/opencode/mcp-auth.json`.
The `mcp debug` command shows the current auth status, tests HTTP connectivity, and attempts the OAuth discovery flow.
---
## Manage
Your MCPs are available as tools in OpenCode, alongside built-in tools. So you
can manage them through the OpenCode config like any other tool.
Your MCPs are available as tools in OpenCode, alongside built-in tools. So you can manage them through the OpenCode config like any other tool.
---
@@ -299,11 +322,10 @@ Here we are using the glob pattern `my-mcp*` to disable all MCPs.
### Per agent
If you have a large number of MCP servers you may want to only enable them per
agent and disable them globally. To do this:
If you have a large number of MCP servers you may want to only enable them per agent and disable them globally. To do this:
1. Disable it as a tool globally.
2. In your [agent config](/docs/agents#tools) enable the MCP server as a tool.
2. In your [agent config](/docs/agents#tools), enable the MCP server as a tool.
```json title="opencode.json" {11, 14-18}
{
@@ -346,6 +368,39 @@ Below are examples of some common MCP servers. You can submit a PR if you want t
---
### Sentry
Add the [Sentry MCP server](https://mcp.sentry.dev) to interact with your Sentry projects and issues.
```json title="opencode.json" {4-8}
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"sentry": {
"type": "remote",
"url": "https://mcp.sentry.dev/mcp",
"oauth": {}
}
}
}
```
After adding the configuration, authenticate with Sentry:
```bash
opencode mcp auth sentry
```
This will open a browser window to complete the OAuth flow and connect OpenCode to your Sentry account.
Once authenticated, you can use Sentry tools in your prompts to query issues, projects, and error data.
```txt "use sentry"
Show me the latest unresolved issues in my project. use sentry
```
---
### Context7
Add the [Context7 MCP server](https://github.com/upstash/context7) to search through docs.
@@ -387,8 +442,7 @@ Add `use context7` to your prompts to use Context7 MCP server.
Configure a Cloudflare Worker script to cache JSON API responses for five minutes. use context7
```
Alternatively, you can add something like this to your
[AGENTS.md](/docs/rules/).
Alternatively, you can add something like this to your [AGENTS.md](/docs/rules/).
```md title="AGENTS.md"
When you need to search docs, use `context7` tools.
@@ -418,9 +472,8 @@ Since we named our MCP server `gh_grep`, you can add `use the gh_grep tool` to y
What's the right way to set a custom domain in an SST Astro component? use the gh_grep tool
```
Alternatively, you can add something like this to your
[AGENTS.md](/docs/rules/).
Alternatively, you can add something like this to your [AGENTS.md](/docs/rules/).
```md title="AGENTS.md"
If you are unsure how to do something, use `gh_grep` to search code examples from github.
If you are unsure how to do something, use `gh_grep` to search code examples from GitHub.
```
+35 -2
View File
@@ -11,6 +11,7 @@ By default, OpenCode allows most operations without approval, except `doom_loop`
"permission": {
"edit": "allow",
"bash": "ask",
"skill": "ask",
"webfetch": "deny",
"doom_loop": "ask",
"external_directory": "ask"
@@ -18,7 +19,7 @@ By default, OpenCode allows most operations without approval, except `doom_loop`
}
```
This lets you configure granular controls for the `edit`, `bash`, `webfetch`, `doom_loop`, and `external_directory` tools.
This lets you configure granular controls for the `edit`, `bash`, `skill`, `webfetch`, `doom_loop`, and `external_directory` tools.
- `"ask"` — Prompt for approval before running the tool
- `"allow"` — Allow all operations without approval
@@ -28,7 +29,7 @@ This lets you configure granular controls for the `edit`, `bash`, `webfetch`, `d
## Tools
Currently, the permissions for the `edit`, `bash`, `webfetch`, `doom_loop`, and `external_directory` tools can be configured through the `permission` option.
Currently, the permissions for the `edit`, `bash`, `skill`, `webfetch`, `doom_loop`, and `external_directory` tools can be configured through the `permission` option.
---
@@ -144,6 +145,38 @@ When an agent asks for permission to run a command in a pipeline, we use tree si
---
### skill
Use the `permission.skill` key to control whether the model can load skills via the built-in `skill` tool.
You can apply a single rule to all skills:
```json title="opencode.json" {4}
{
"$schema": "https://opencode.ai/config.json",
"permission": {
"skill": "ask"
}
}
```
Or configure per-skill rules (supports the same wildcard patterns as `permission.bash`):
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"permission": {
"skill": {
"*": "deny",
"git-*": "allow",
"frontend/*": "ask"
}
}
}
```
---
### webfetch
Use the `permission.webfetch` key to control whether the LLM can fetch web pages.
@@ -323,6 +323,64 @@ If you encounter "I'm sorry, but I cannot assist with that request" errors, try
---
### Cloudflare AI Gateway
Cloudflare AI Gateway lets you access models from OpenAI, Anthropic, Workers AI, and more through a unified endpoint. With [Unified Billing](https://developers.cloudflare.com/ai-gateway/features/unified-billing/) you don't need separate API keys for each provider.
1. Head over to the [Cloudflare dashboard](https://dash.cloudflare.com/), navigate to **AI** > **AI Gateway**, and create a new gateway.
2. Set your Account ID and Gateway ID as environment variables.
```bash title="~/.bash_profile"
export CLOUDFLARE_ACCOUNT_ID=your-32-character-account-id
export CLOUDFLARE_GATEWAY_ID=your-gateway-id
```
3. Run the `/connect` command and search for **Cloudflare AI Gateway**.
```txt
/connect
```
4. Enter your Cloudflare API token.
```txt
┌ API key
└ enter
```
Or set it as an environment variable.
```bash title="~/.bash_profile"
export CLOUDFLARE_API_TOKEN=your-api-token
```
5. Run the `/models` command to select a model.
```txt
/models
```
You can also add models through your opencode config.
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"cloudflare-ai-gateway": {
"models": {
"openai/gpt-4o": {},
"anthropic/claude-sonnet-4": {}
}
}
}
}
```
---
### Cortecs
1. Head over to the [Cortecs console](https://cortecs.ai/), create an account, and generate an API key.

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