Compare commits

...

47 Commits

Author SHA1 Message Date
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
66 changed files with 1203 additions and 684 deletions

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

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:

View File

@@ -179,3 +179,4 @@
| 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) |

View File

@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.0.193",
"version": "1.0.197",
"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.197",
"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.197",
"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.197",
"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.197",
"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.197",
"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.197",
"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.197",
"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.197",
"bin": {
"opencode": "./bin/opencode",
},
@@ -346,7 +346,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.0.193",
"version": "1.0.197",
"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.197",
"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.197",
"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.197",
"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.197",
"dependencies": {
"zod": "catalog:",
},
@@ -436,7 +436,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.0.193",
"version": "1.0.197",
"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=="],

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1766410818,
"narHash": "sha256-ruVneSx6wFy5PMw1ow3BE+znl653TJ6+eeNUj4B/9y8=",
"lastModified": 1766532406,
"narHash": "sha256-acLU/ag9VEoKkzOD202QASX25nG1eArXg5A0mHjKgxM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3a7affa77a5a539afa1c7859e2c31abdb1aeadf3",
"rev": "8142186f001295e5a3239f485c8a49bf2de2695a",
"type": "github"
},
"original": {

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", {

View File

@@ -1,3 +1,3 @@
{
"nodeModules": "sha256-CDOAY2h2AAcSuVqV1uyxDmfzSa/vV8lnXOKDgAC4mgg="
"nodeModules": "sha256-hotsyeWJA6/dP6DvZTN1Ak2RSKcsyvXlXPI/jexBHME="
}

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",

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"

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.0.193",
"version": "1.0.197",
"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:",

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={

View File

@@ -136,7 +136,19 @@ 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 ?? [])),
@@ -320,7 +332,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(() =>

View File

@@ -116,42 +116,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 +320,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)
layout.projects.expand(directory)
requestAnimationFrame(() => scrollToSession(id))
})
createEffect(() => {
@@ -455,13 +447,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 +464,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
@@ -530,18 +518,6 @@ export default function Layout(props: ParentProps) {
</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 +528,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)
@@ -624,13 +587,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 +709,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)}>

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(),
},
{

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.0.193",
"version": "1.0.197",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",

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.197",
"private": true,
"type": "module",
"dependencies": {

View File

@@ -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}`

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}`

View File

@@ -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}`

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)
})

View File

@@ -118,6 +118,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS6": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.193",
"version": "1.0.197",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -118,6 +118,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS6": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.193",
"version": "1.0.197",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -118,6 +118,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS6": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.0.193",
"version": "1.0.197",
"type": "module",
"scripts": {
"typecheck": "tsgo -b",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.0.193",
"version": "1.0.197",
"private": true,
"type": "module",
"scripts": {

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>

View File

@@ -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()} />

View File

@@ -118,6 +118,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS6": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.0.193"
version = "1.0.197"
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.197/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.197/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.197/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.197/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.197/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.0.193",
"version": "1.0.197",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -118,6 +118,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS6": {
"type": "sst.sst.Secret"
"value": string
}
}
}
// cloudflare

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.193",
"version": "1.0.197",
"name": "opencode",
"type": "module",
"private": true,

View File

@@ -179,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(() => {
@@ -447,7 +461,7 @@ function App() {
{
title: "Toggle console",
category: "System",
value: "app.fps",
value: "app.console",
onSelect: (dialog) => {
renderer.console.toggle()
dialog.clear()

View File

@@ -16,7 +16,7 @@ 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"
@@ -123,9 +123,6 @@ export function Prompt(props: PromptProps) {
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() {
@@ -949,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
@@ -993,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>
</>

View File

@@ -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}

View File

@@ -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>
)

View File

@@ -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>
)

View File

@@ -73,7 +73,6 @@ export function Sidebar(props: { sessionID: string }) {
<box
backgroundColor={theme.backgroundPanel}
width={42}
height="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}

View File

@@ -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}

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",

View File

@@ -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

View File

@@ -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")
})
})

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.0.193",
"version": "1.0.197",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
@@ -24,4 +24,4 @@
"typescript": "catalog:",
"@typescript/native-preview": "catalog:"
}
}
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.0.193",
"version": "1.0.197",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
@@ -29,4 +29,4 @@
"publishConfig": {
"directory": "dist"
}
}
}

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

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"
},

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.0.193",
"version": "1.0.197",
"type": "module",
"scripts": {
"dev": "bun run src/index.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.0.193",
"version": "1.0.197",
"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:",

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" />

View File

@@ -2,9 +2,18 @@ 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
const trimmed = text.trim()
const match = trimmed.match(/^<([A-Za-z]\w*)>/)
if (!match) return text
const tagName = match[1]
const closingTag = `</${tagName}>`
if (trimmed.endsWith(closingTag)) {
const content = trimmed.slice(match[0].length, -closingTag.length)
return content.trim()
}
return text
}
export function Markdown(

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,
}}
/>
)

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,66 +102,68 @@ 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 parts = createMemo(() => {
const msg = message()
if (!msg) return []
return data.store.part[msg.id] ?? []
})
const assistantParts: PartType[] = []
for (const m of assistantMessages) {
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 assistantParts = createMemo(() => {
const result: 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)
if (p) result.push(p)
}
}
}
return result
})
const lastAssistantMessage = assistantMessages.at(-1)
const error = assistantMessages.find((m) => m.error)?.error
const lastAssistantMessage = createMemo(() => assistantMessages().at(-1))
let lastTextPart: PartType | undefined
for (let i = assistantParts.length - 1; i >= 0; i--) {
if (assistantParts[i]?.type === "text") {
lastTextPart = assistantParts[i]
break
}
const error = createMemo(() => assistantMessages().find((m) => m.error)?.error)
const lastTextPart = createMemo(() => {
const ap = assistantParts()
for (let i = ap.length - 1; i >= 0; i--) {
if (ap[i]?.type === "text") return ap[i]
}
return undefined
})
const hasSteps = assistantParts.some((p) => p?.type === "tool")
const hasSteps = createMemo(() => 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
}
const isShellMode = createMemo(() => {
const p = parts()
const ap = assistantParts()
if (p.every((part) => part?.type === "text" && part?.synthetic) && ap.length === 1) {
const assistantPart = ap[0]
if (assistantPart?.type === "tool" && assistantPart?.tool === "bash") return true
}
return false
})
let resolvedParts = assistantParts
const currentTask = assistantParts.findLast(
const rawStatus = createMemo(() => {
const ap = assistantParts()
const currentTask = ap.findLast(
(p) =>
p &&
p.type === "tool" &&
@@ -148,6 +175,7 @@ export function SessionTurn(
p.state.status === "running",
) as ToolPart | undefined
let resolvedParts = ap
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",
@@ -162,48 +190,20 @@ export function SessionTurn(
}
}
}
if (taskParts.length > 0) {
resolvedParts = taskParts
}
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,
}
return computeStatusFromPart(resolvedParts.at(-1))
})
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 status = createMemo(
() =>
data.store.session_status[props.sessionID] ?? {
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 +246,7 @@ export function SessionTurn(
retrySeconds: 0,
status: rawStatus(),
duration: duration(),
summaryWaitTimedOut: false,
})
createEffect(() => {
@@ -286,6 +287,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(() => {
@@ -362,7 +399,7 @@ export function SessionTurn(
size="small"
onClick={props.onStepsExpandedToggle ?? (() => {})}
>
<Show when={working()}>
<Show when={working() || waitingForSummary()}>
<Spinner />
</Show>
<Switch>
@@ -379,6 +416,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 +433,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 +451,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>

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,

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.0.193",
"version": "1.0.197",
"private": true,
"type": "module",
"exports": {

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/web",
"type": "module",
"version": "1.0.193",
"version": "1.0.197",
"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:",

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 |

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

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",

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:
@@ -173,11 +168,13 @@ 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 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,31 +239,16 @@ 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. |
#### Authenticating
You can manually trigger authentication or manage credentials:
```bash
# Authenticate with a specific MCP server
opencode mcp auth my-oauth-server
# List all MCP servers and their auth status
opencode mcp list
# Remove stored credentials
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`.
| 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. |
#### Debugging
@@ -258,8 +268,7 @@ The `mcp debug` command shows the current auth status, tests HTTP connectivity,
## 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.
---
@@ -313,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}
{
@@ -360,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.
@@ -401,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.
@@ -432,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.
```

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.

View File

@@ -213,6 +213,29 @@ This tool lists directory contents. It accepts glob patterns to filter results.
---
### lsp (experimental)
Interact with your configured LSP servers to get code intelligence features like definitions, references, hover info, and call hierarchy.
:::note
This tool is only available when `OPENCODE_EXPERIMENTAL_LSP_TOOL=true` (or `OPENCODE_EXPERIMENTAL=true`).
:::
```json title="opencode.json" {4}
{
"$schema": "https://opencode.ai/config.json",
"tools": {
"lsp": true
}
}
```
Supported operations include `goToDefinition`, `findReferences`, `hover`, `documentSymbol`, `workspaceSymbol`, `goToImplementation`, `prepareCallHierarchy`, `incomingCalls`, and `outgoingCalls`.
To configure which LSP servers are available for your project, see [LSP Servers](/docs/lsp).
---
### patch
Apply patches to files.
@@ -230,6 +253,23 @@ This tool applies patch files to your codebase. Useful for applying diffs and pa
---
### skill
Load a [skill](/docs/skills) (a `SKILL.md` file) and return its content in the conversation.
```json title="opencode.json" {4}
{
"$schema": "https://opencode.ai/config.json",
"tools": {
"skill": true
}
}
```
You can control approval prompts for loading skills via [permissions](/docs/permissions) using `permission.skill`.
---
### todowrite
Manage todo lists during coding sessions.

View File

@@ -325,6 +325,7 @@ Popular editor options include:
- `code` - Visual Studio Code
- `cursor` - Cursor
- `windsurf` - Windsurf
- `nvim` - Neovim editor
- `vim` - Vim editor
- `nano` - Nano editor
- `notepad` - Windows Notepad
@@ -361,11 +362,13 @@ You can customize TUI behavior through your OpenCode config file.
---
## View customization
## Customization
You can customize various aspects of the TUI view using the command palette (`ctrl+x h` or `/help`). These settings persist across restarts.
### Username display
---
#### Username display
Toggle whether your username appears in chat messages. Access this through:

View File

@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.0.193",
"version": "1.0.197",
"publisher": "sst-dev",
"repository": {
"type": "git",

4
sst-env.d.ts vendored
View File

@@ -144,6 +144,10 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS6": {
"type": "sst.sst.Secret"
"value": string
}
"ZenData": {
"name": string
"type": "sst.cloudflare.Bucket"