Compare commits

...

39 Commits

Author SHA1 Message Date
opencode 6f9bea4e1f release: v1.0.200 2025-12-25 04:39:32 +00:00
Adam 5c49b4cbfc fix(desktop): scroll jank in session turn and review 2025-12-24 22:36:45 -06:00
GitHub Action b746e831e2 chore: generate 2025-12-25 04:17:39 +00:00
Adam 2178deef91 fix(desktop): override agent model 2025-12-24 22:16:58 -06:00
Adam b1d2fb5319 fix(desktop): reconcile session diff updates 2025-12-24 22:16:44 -06:00
opencode 2284a4e6df release: v1.0.199 2025-12-25 02:40:19 +00:00
Adam ad852d9186 chore: toast on file load error 2025-12-24 20:37:12 -06:00
Adam 8a9b4245b4 chore: cleanup dead code 2025-12-24 20:30:52 -06:00
Adam 76ac1ccb6b chore: show version on error page 2025-12-24 20:25:36 -06:00
Adam e71bc8c0b0 fix(desktop): show server connection failure 2025-12-24 20:11:37 -06:00
Ahmed Mansour a5301e2ab7 fix: correct Content-Type headers for static assets on app.opencode.ai (#6113) 2025-12-24 20:00:22 -06:00
Connor Adams 8eac72341f docs: update skills to use canonical ~/.config/opencode location (#6132) 2025-12-24 18:18:33 -06:00
Robb Tolliver bd139b4bd6 docs: Corrected the number of built-in subagents in documentation (#6133) 2025-12-24 18:02:35 -06:00
GitHub Action 508578bf17 chore: generate 2025-12-24 19:21:10 +00:00
Dax Raad 607d8aafb7 tui: disable tips display in home route 2025-12-24 14:20:29 -05:00
Dax Raad 5843eca7d6 CI 2025-12-24 18:16:46 +00:00
opencode ff3b68bd36 release: v1.0.198 2025-12-24 18:16:46 +00:00
Dax Raad 474b6fd3d1 ci 2025-12-24 13:12:29 -05:00
GitHub Action 6145b197f3 chore: generate 2025-12-24 18:08:42 +00:00
Dax Raad 918eff9233 ci 2025-12-24 13:07:56 -05:00
opencode 987e444828 release: v1.0.197 2025-12-24 17:47:07 +00:00
Dax Raad 99633cb299 Revert "feat: better styling for small screens (short and/or not wide) (#5968)"
This reverts commit ac371d2987.
2025-12-24 12:38:10 -05:00
GitHub Action f822331eb8 chore: generate 2025-12-24 17:07:43 +00:00
Patrick Schiel 0f053769db docs: add infos about server debugging (#6085) 2025-12-24 11:07:12 -06:00
opencode ceeaf494c4 release: v1.0.196 2025-12-24 16:40:16 +00:00
Adam 126d887e57 fix(desktop): last text part streaming 2025-12-24 10:35:52 -06:00
Adam e5cfc24d6b fix(desktop): render perf 2025-12-24 10:26:49 -06:00
Jay V 7f8d659737 docs: edits 2025-12-24 11:23:51 -05:00
Jay V 4b061653f2 docs: add comprehensive CLI command documentation for agent, mcp, session, stats, and web commands 2025-12-24 11:12:09 -05:00
Jay V eeed89f985 docs: make MCP server documentation more scannable and add Sentry example 2025-12-24 10:49:48 -05:00
Adam 8ab533b616 chore: cleanup 2025-12-24 09:07:31 -06:00
Adam 09a399d8d6 fix(desktop): summary flicker 2025-12-24 09:07:31 -06:00
Adam b75575884a feat(desktop): show read tool args 2025-12-24 09:07:31 -06:00
GitHub Action 5688c9fd61 chore: generate 2025-12-24 14:56:15 +00:00
Adam 08a075df61 fix(desktop): better session navigation, hide child sessions 2025-12-24 08:55:32 -06:00
opencode a2e8737114 release: v1.0.195 2025-12-24 14:50:40 +00:00
Adam 776a394b02 chore: cleanup 2025-12-24 08:46:11 -06:00
GitHub Action 5788b33fdf chore: generate 2025-12-24 14:38:25 +00:00
Adam 0f270c3da4 refactor(ui): rewrite createAutoScroll with robust event tracking to fix sticky behavior 2025-12-24 08:37:49 -06:00
46 changed files with 985 additions and 736 deletions
+2
View File
@@ -6,6 +6,8 @@ You are an expert technical documentation writer
You are not verbose
Use a relaxed and friendly tone
The title of the page should be a word or a 2-3 word phrase
The description should be one short line, should not start with "The", should
+4 -4
View File
@@ -53,12 +53,12 @@ your debugger via that URL. Other methods can result in breakpoints being mapped
Caveats:
- `*.tsx` files won't have their breakpoints correctly mapped. This seems due to Bun currently not supporting source maps on code transformed
via `BunPlugin`s (currently necessary due to our dependency on `@opentui/solid`). Currently, the best you can do in terms of debugging `*.tsx`
files is writing a `debugger;` statement. Debugging facilities like stepping won't work, but at least you will be informed if a specific code
is triggered.
- If you want to run the OpenCode TUI and have breakpoints triggered in the server code, you might need to run `bun dev spawn` instead of
the usual `bun dev`. This is because `bun dev` runs the server in a worker thread and breakpoints might not work there.
- If `spawn` does not work for you, you can debug the server separately:
- Debug server: `bun run --inspect=ws://localhost:6499/ ./src/index.ts serve --port 4096`,
then attach TUI with `opencode attach http://localhost:4096`
- Debug TUI: `bun run --inspect=ws://localhost:6499/ --conditions=browser ./src/index.ts`
Other tips and tricks:
+15 -15
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.0.194",
"version": "1.0.200",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -77,7 +77,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.0.194",
"version": "1.0.200",
"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.194",
"version": "1.0.200",
"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.194",
"version": "1.0.200",
"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.194",
"version": "1.0.200",
"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.194",
"version": "1.0.200",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@solid-primitives/storage": "catalog:",
@@ -207,7 +207,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.0.194",
"version": "1.0.200",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -236,7 +236,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.0.194",
"version": "1.0.200",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -252,7 +252,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.0.194",
"version": "1.0.200",
"bin": {
"opencode": "./bin/opencode",
},
@@ -346,7 +346,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.0.194",
"version": "1.0.200",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -366,7 +366,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.0.194",
"version": "1.0.200",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -377,7 +377,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.0.194",
"version": "1.0.200",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -390,7 +390,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.0.194",
"version": "1.0.200",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -425,7 +425,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.0.194",
"version": "1.0.200",
"dependencies": {
"zod": "catalog:",
},
@@ -436,7 +436,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.0.194",
"version": "1.0.200",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.0.194",
"version": "1.0.200",
"description": "",
"type": "module",
"exports": {
+17
View File
@@ -0,0 +1,17 @@
/assets/*.js
Content-Type: application/javascript
/assets/*.mjs
Content-Type: application/javascript
/assets/*.css
Content-Type: text/css
/*.js
Content-Type: application/javascript
/*.mjs
Content-Type: application/javascript
/*.css
Content-Type: text/css
+5 -5
View File
@@ -2,7 +2,7 @@ import { useLocal, type LocalFile } from "@/context/local"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"
import { For, Match, Switch, type ComponentProps, type ParentProps } from "solid-js"
import { Dynamic } from "solid-js/web"
export default function FileTree(props: {
@@ -57,14 +57,14 @@ export default function FileTree(props: {
"text-text-muted/40": p.node.ignored,
"text-text-muted/80": !p.node.ignored,
// "!text-text": local.file.active()?.path === p.node.path,
"!text-primary": local.file.changed(p.node.path),
// "!text-primary": local.file.changed(p.node.path),
}}
>
{p.node.name}
</span>
<Show when={local.file.changed(p.node.path)}>
<span class="ml-auto mr-1 w-1.5 h-1.5 rounded-full bg-primary/50 shrink-0" />
</Show>
{/* <Show when={local.file.changed(p.node.path)}> */}
{/* <span class="ml-auto mr-1 w-1.5 h-1.5 rounded-full bg-primary/50 shrink-0" /> */}
{/* </Show> */}
</Dynamic>
)
+7 -12
View File
@@ -5,8 +5,6 @@ import {
type Part,
type Config,
type Path,
type File,
type FileNode,
type Project,
type FileDiff,
type Todo,
@@ -50,8 +48,6 @@ type State = {
part: {
[messageID: string]: Part[]
}
node: FileNode[]
changes: File[]
}
function createGlobalSync() {
@@ -92,8 +88,6 @@ function createGlobalSync() {
limit: 5,
message: {},
part: {},
node: [],
changes: [],
})
children[directory] = createStore(globalStore.children[directory])
bootstrapInstance(directory)
@@ -155,8 +149,6 @@ function createGlobalSync() {
session: () => loadSessions(directory),
status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)),
node: () => sdk.file.list({ path: "/" }).then((x) => setStore("node", x.data!)),
}
await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e))))
.then(() => setStore("ready", true))
@@ -223,13 +215,13 @@ function createGlobalSync() {
break
}
case "session.diff":
setStore("session_diff", event.properties.sessionID, event.properties.diff)
setStore("session_diff", event.properties.sessionID, reconcile(event.properties.diff, { key: "file" }))
break
case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
setStore("todo", event.properties.sessionID, reconcile(event.properties.todos))
break
case "session.status": {
setStore("session_status", event.properties.sessionID, event.properties.status)
setStore("session_status", event.properties.sessionID, reconcile(event.properties.status))
break
}
case "message.updated": {
@@ -307,7 +299,10 @@ function createGlobalSync() {
})
async function bootstrap() {
const health = await globalSDK.client.global.health().then((x) => x.data)
const health = await globalSDK.client.global
.health()
.then((x) => x.data)
.catch(() => undefined)
if (!health?.healthy) {
setGlobalStore(
"error",
+37 -45
View File
@@ -1,5 +1,5 @@
import { createStore, produce, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo } from "solid-js"
import { batch, createMemo } from "solid-js"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "@opencode-ai/ui/context"
@@ -9,6 +9,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { useProviders } from "@/hooks/use-providers"
import { DateTime } from "luxon"
import { persisted } from "@/utils/persist"
import { showToast } from "@opencode-ai/ui/toast"
export type LocalFile = FileNode &
Partial<{
@@ -61,24 +62,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
}
// Automatically update model when agent changes
createEffect(() => {
const value = agent.current()
if (value.model) {
if (isModelValid(value.model))
model.set({
providerID: value.model.providerID,
modelID: value.model.modelID,
})
// else
// toast.show({
// type: "warning",
// message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
// duration: 3000,
// })
}
})
const agent = (() => {
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
const [store, setStore] = createStore<{
@@ -276,11 +259,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [store, setStore] = createStore<{
node: Record<string, LocalFile>
}>({
node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
node: {}, // Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
})
const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
// const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
// const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
// createEffect((prev: FileStatus[]) => {
// const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path))
@@ -308,16 +291,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
// return sync.data.changes
// }, sync.data.changes)
const changed = (path: string) => {
const node = store.node[path]
if (node?.status) return true
const set = changeset()
if (set.has(path)) return true
for (const p of set) {
if (p.startsWith(path ? path + "/" : "")) return true
}
return false
}
// const changed = (path: string) => {
// const node = store.node[path]
// if (node?.status) return true
// const set = changeset()
// if (set.has(path)) return true
// for (const p of set) {
// if (p.startsWith(path ? path + "/" : "")) return true
// }
// return false
// }
// const resetNode = (path: string) => {
// setStore("node", path, {
@@ -336,17 +319,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const load = async (path: string) => {
const relativePath = relative(path)
await sdk.client.file.read({ path: relativePath }).then((x) => {
if (!store.node[relativePath]) return
setStore(
"node",
relativePath,
produce((draft) => {
draft.loaded = true
draft.content = x.data
}),
)
})
await sdk.client.file
.read({ path: relativePath })
.then((x) => {
if (!store.node[relativePath]) return
setStore(
"node",
relativePath,
produce((draft) => {
draft.loaded = true
draft.content = x.data
}),
)
})
.catch((e) => {
showToast({
variant: "error",
title: "Failed to load file",
description: e.message,
})
})
}
const fetch = async (path: string) => {
@@ -466,8 +458,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setChangeIndex(path: string, index: number | undefined) {
setStore("node", path, "selectedChange", index)
},
changes,
changed,
// changes,
// changed,
children(path: string) {
return Object.values(store.node).filter(
(x) =>
+3
View File
@@ -5,6 +5,9 @@ export type Platform = {
/** Platform discriminator */
platform: "web" | "tauri"
/** App version */
version?: string
/** Open a URL in the default browser */
openLink(url: string): void
+2
View File
@@ -2,6 +2,7 @@
import { render } from "solid-js/web"
import { App } from "@/app"
import { Platform, PlatformProvider } from "@/context/platform"
import pkg from "../package.json"
const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
@@ -12,6 +13,7 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
const platform: Platform = {
platform: "web",
version: pkg.version,
openLink(url: string) {
window.open(url, "_blank")
},
+16 -11
View File
@@ -1,7 +1,7 @@
import { TextField } from "@opencode-ai/ui/text-field"
import { Logo } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button"
import { Component } from "solid-js"
import { Component, Show } from "solid-js"
import { usePlatform } from "@/context/platform"
import { Icon } from "@opencode-ai/ui/icon"
@@ -138,16 +138,21 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
<Button size="large" onClick={platform.restart}>
Restart
</Button>
<div class="flex items-center justify-center gap-1">
Please report this error to the OpenCode team
<button
type="button"
class="flex items-center text-text-interactive-base gap-1"
onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
>
<div>on Discord</div>
<Icon name="discord" class="text-text-interactive-base" />
</button>
<div class="flex flex-col items-center gap-2">
<div class="flex items-center justify-center gap-1">
Please report this error to the OpenCode team
<button
type="button"
class="flex items-center text-text-interactive-base gap-1"
onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
>
<div>on Discord</div>
<Icon name="discord" class="text-text-interactive-base" />
</button>
</div>
<Show when={platform.version}>
<p class="text-xs text-text-weak">Version: {platform.version}</p>
</Show>
</div>
</div>
</div>
+12 -62
View File
@@ -129,40 +129,18 @@ export default function Layout(props: ParentProps) {
return bUpdated - aUpdated
}
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 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(sortSessions)
return flattenSessions(sessions ?? [])
return (sessions ?? []).filter((s) => !s.parentID)
}
const currentSessions = createMemo(() => {
@@ -342,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(() => {
@@ -466,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"))
@@ -487,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
@@ -541,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>
</>
)
}
@@ -565,17 +530,6 @@ export default function Layout(props: ParentProps) {
const [store, setProjectStore] = globalSync.child(props.project.worktree)
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)
@@ -633,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}>
@@ -761,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)}>
+2
View File
@@ -11,6 +11,7 @@
"jsx": "preserve",
"jsxImportSource": "solid-js",
"allowJs": true,
"resolveJsonModule": true,
"strict": true,
"noEmit": false,
"emitDeclarationOnly": true,
@@ -20,5 +21,6 @@
"@/*": ["./src/*"]
}
},
"include": ["src", "package.json"],
"exclude": ["dist", "ts-dist"]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.0.194",
"version": "1.0.200",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.194",
"version": "1.0.200",
"private": true,
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.194",
"version": "1.0.200",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.194",
"version": "1.0.200",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.0.194",
"version": "1.0.200",
"type": "module",
"scripts": {
"typecheck": "tsgo -b",
+2
View File
@@ -13,6 +13,7 @@ import { createMenu } from "./menu"
import { check, Update } from "@tauri-apps/plugin-updater"
import { invoke } from "@tauri-apps/api/core"
import { relaunch } from "@tauri-apps/plugin-process"
import pkg from "../package.json"
const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
@@ -25,6 +26,7 @@ let update: Update | null = null
const platform: Platform = {
platform: "tauri",
version: pkg.version,
async openDirectoryPickerDialog(opts) {
const result = await open({
+2 -1
View File
@@ -9,6 +9,7 @@
"jsx": "preserve",
"jsxImportSource": "solid-js",
"allowJs": true,
"resolveJsonModule": true,
"strict": true,
"isolatedModules": true,
"noEmit": true,
@@ -16,5 +17,5 @@
"outDir": "node_modules/.ts-dist"
},
"references": [{ "path": "../app" }],
"include": ["src"]
"include": ["src", "package.json"]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.0.194",
"version": "1.0.200",
"private": true,
"type": "module",
"scripts": {
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.0.194"
version = "1.0.200"
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.194/opencode-darwin-arm64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.200/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.194/opencode-darwin-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.200/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.194/opencode-linux-arm64.tar.gz"
archive = "https://github.com/sst/opencode/releases/download/v1.0.200/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.194/opencode-linux-x64.tar.gz"
archive = "https://github.com/sst/opencode/releases/download/v1.0.200/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.194/opencode-windows-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.200/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.0.194",
"version": "1.0.200",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.194",
"version": "1.0.200",
"name": "opencode",
"type": "module",
"private": true,
@@ -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>
</>
@@ -36,6 +36,7 @@ export function Home() {
const isFirstTimeUser = createMemo(() => sync.data.session.length === 0)
const tipsHidden = createMemo(() => kv.get("tips_hidden", false))
const showTips = createMemo(() => {
return false
// Don't show tips for first-time users
if (isFirstTimeUser()) return false
return !tipsHidden()
@@ -1,140 +1,124 @@
import { type Accessor, createMemo, Match, Show, Switch } from "solid-js"
import { useRouteData } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
import { pipe, sumBy } from "remeda"
import { useTheme } from "@tui/context/theme"
import { EmptyBorder } from "@tui/component/border"
import type { Session } from "@opencode-ai/sdk/v2"
import { SplitBorder, EmptyBorder } from "@tui/component/border"
import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2"
import { useDirectory } from "../../context/directory"
import { useKeybind } from "../../context/keybind"
import { useTerminalDimensions } from "@opentui/solid"
const Title = (props: { session: Accessor<Session>; truncate?: boolean }) => {
const Title = (props: { session: Accessor<Session> }) => {
const { theme } = useTheme()
return (
<text fg={theme.text} wrapMode={props.truncate ? "none" : undefined} flexShrink={props.truncate ? 1 : 0}>
<text fg={theme.text}>
<span style={{ bold: true }}>#</span> <span style={{ bold: true }}>{props.session().title}</span>
</text>
)
}
const ContextInfo = (props: { context: Accessor<string | undefined>; cost: Accessor<string> }) => {
const { theme } = useTheme()
return (
<Show when={props.context()}>
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
{props.context()} ({props.cost()})
</text>
</Show>
)
}
export function Header() {
const route = useRouteData("session")
const sync = useSync()
const session = createMemo(() => sync.session.get(route.sessionID)!)
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const showShare = createMemo(() => shareEnabled() && !session()?.share?.url)
const cost = createMemo(() => {
const total = pipe(
messages(),
sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
)
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(total)
})
const context = createMemo(() => {
const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
if (!last) return
const total =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
const model = sync.data.provider.find((x) => x.id === last.providerID)?.models[last.modelID]
let result = total.toLocaleString()
if (model?.limit.context) {
result += " " + Math.round((total / model.limit.context) * 100) + "%"
}
return result
})
const { theme } = useTheme()
const keybind = useKeybind()
const dimensions = useTerminalDimensions()
const tall = createMemo(() => dimensions().height > 40)
return (
<box flexShrink={0}>
<box
height={1}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={1}
{...SplitBorder}
border={["left"]}
borderColor={theme.border}
customBorderChars={{
...EmptyBorder,
vertical: theme.backgroundPanel.a !== 0 ? "╻" : " ",
}}
flexShrink={0}
backgroundColor={theme.backgroundPanel}
>
<box
height={1}
border={["top"]}
borderColor={theme.backgroundPanel}
customBorderChars={
theme.backgroundPanel.a !== 0
? {
...EmptyBorder,
horizontal: "▄",
}
: {
...EmptyBorder,
horizontal: " ",
}
}
/>
</box>
<box
border={["left"]}
borderColor={theme.border}
customBorderChars={{
...EmptyBorder,
vertical: "┃",
bottomLeft: "╹",
}}
>
<box
paddingTop={tall() ? 1 : 0}
paddingBottom={tall() ? 1 : 0}
paddingLeft={2}
paddingRight={1}
flexShrink={0}
flexGrow={1}
backgroundColor={theme.backgroundPanel}
>
<Switch>
<Match when={session()?.parentID}>
<box flexDirection="row" gap={2}>
<text fg={theme.text}>
<b>Subagent session</b>
</text>
<text fg={theme.text}>
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
</text>
<text fg={theme.text}>
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
</text>
<text fg={theme.text}>
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
</text>
<box flexGrow={1} flexShrink={1} />
<Show when={showShare()}>
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
/share{" "}
</text>
</Show>
</box>
</Match>
<Match when={true}>
<Switch>
<Match when={session()?.parentID}>
<box flexDirection="row" gap={2}>
<text fg={theme.text}>
<b>Subagent session</b>
</text>
<text fg={theme.text}>
Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
</text>
<text fg={theme.text}>
Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
</text>
<text fg={theme.text}>
Next <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle")}</span>
</text>
<box flexGrow={1} flexShrink={1} />
<ContextInfo context={context} cost={cost} />
</box>
</Match>
<Match when={true}>
<box flexDirection="row" justifyContent="space-between" gap={1}>
<Title session={session} />
<ContextInfo context={context} cost={cost} />
</box>
<Show when={shareEnabled()}>
<box flexDirection="row" justifyContent="space-between" gap={1}>
<Title session={session} truncate={!tall()} />
<Show when={showShare()}>
<text fg={theme.textMuted} wrapMode="none" flexShrink={0}>
/share{" "}
</text>
</Show>
<box flexGrow={1} flexShrink={1}>
<Switch>
<Match when={session().share?.url}>
<text fg={theme.textMuted} wrapMode="word">
{session().share!.url}
</text>
</Match>
<Match when={true}>
<text fg={theme.text} wrapMode="word">
/share <span style={{ fg: theme.textMuted }}>copy link</span>
</text>
</Match>
</Switch>
</box>
</box>
</Match>
</Switch>
</box>
</box>
<box
height={1}
border={["left"]}
borderColor={theme.border}
customBorderChars={{
...EmptyBorder,
vertical: theme.backgroundPanel.a !== 0 ? "╹" : " ",
}}
>
<box
height={1}
border={["bottom"]}
borderColor={theme.backgroundPanel}
customBorderChars={
theme.backgroundPanel.a !== 0
? {
...EmptyBorder,
horizontal: "▀",
}
: {
...EmptyBorder,
horizontal: " ",
}
}
/>
</Show>
</Match>
</Switch>
</box>
</box>
)
@@ -22,7 +22,6 @@ import {
ScrollBoxRenderable,
addDefaultParsers,
MacOSScrollAccel,
RGBA,
type ScrollAcceleration,
} from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt"
@@ -131,15 +130,13 @@ export function Session() {
const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word")
const wide = createMemo(() => dimensions().width > 120)
const tall = createMemo(() => dimensions().height > 40)
const sidebarVisible = createMemo(() => {
if (session()?.parentID) return false
if (sidebar() === "show") return true
if (sidebar() === "auto" && wide()) return true
return false
})
const sidebarOverlay = createMemo(() => sidebarVisible() && !wide())
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() && !sidebarOverlay() ? 42 : 0) - 4)
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
const scrollAcceleration = createMemo(() => {
const tui = sync.data.config.tui
@@ -965,7 +962,7 @@ export function Session() {
<box flexDirection="row">
<box flexGrow={1} paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={1}>
<Show when={session()}>
<Show when={!sidebarVisible() || sidebarOverlay()}>
<Show when={!sidebarVisible()}>
<Header />
</Show>
<scrollbox
@@ -1095,33 +1092,15 @@ export function Session() {
sessionID={route.sessionID}
/>
</box>
<Show when={(!sidebarVisible() || sidebarOverlay()) && tall()}>
<Show when={!sidebarVisible()}>
<Footer />
</Show>
</Show>
<Toast />
</box>
<Show when={sidebarVisible() && !sidebarOverlay()}>
<Show when={sidebarVisible()}>
<Sidebar sessionID={route.sessionID} />
</Show>
<Show when={sidebarOverlay()}>
<box
position="absolute"
left={0}
top={0}
width={dimensions().width}
height={dimensions().height}
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
zIndex={100}
flexDirection="row"
justifyContent="flex-end"
onMouseUp={() => setSidebar("hide")}
>
<box onMouseUp={(e) => e.stopPropagation()}>
<Sidebar sessionID={route.sessionID} />
</box>
</box>
</Show>
</box>
</context.Provider>
)
@@ -73,7 +73,6 @@ export function Sidebar(props: { sessionID: string }) {
<box
backgroundColor={theme.backgroundPanel}
width={42}
height="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.0.194",
"version": "1.0.200",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.0.194",
"version": "1.0.200",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.0.194",
"version": "1.0.200",
"type": "module",
"scripts": {
"dev": "bun run src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.0.194",
"version": "1.0.200",
"type": "module",
"exports": {
"./*": "./src/components/*.tsx",
+5 -2
View File
@@ -415,8 +415,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
PART_MAPPING["text"] = function TextPartDisplay(props) {
const data = useData()
const part = props.part as TextPart
const content = createMemo(() => (part.text ?? "").trim())
const displayText = createMemo(() => relativizeProjectPaths(content(), data.directory))
const displayText = () => relativizeProjectPaths((part.text ?? "").trim(), data.directory)
return (
<Show when={displayText()}>
@@ -441,6 +440,9 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
ToolRegistry.register({
name: "read",
render(props) {
const args: string[] = []
if (props.input.offset) args.push("offset=" + props.input.offset)
if (props.input.limit) args.push("limit=" + props.input.limit)
return (
<BasicTool
{...props}
@@ -448,6 +450,7 @@ ToolRegistry.register({
trigger={{
title: "Read",
subtitle: props.input.filePath ? getFilename(props.input.filePath) : "",
args,
}}
/>
)
+185 -140
View File
@@ -60,6 +60,31 @@ function computeStatusFromPart(part: PartType | undefined): string | undefined {
return undefined
}
function AssistantMessageItem(props: {
message: AssistantMessage
summary: string | undefined
response: string | undefined
lastTextPartId: string | undefined
working: boolean
}) {
const data = useData()
const msgParts = createMemo(() => data.store.part[props.message.id] ?? [])
const lastTextPart = createMemo(() =>
msgParts()
.filter((p) => p?.type === "text")
.at(-1),
)
const filteredParts = createMemo(() => {
if (!props.working && !props.summary && props.response && props.lastTextPartId === lastTextPart()?.id) {
return msgParts().filter((p) => p?.id !== lastTextPart()?.id)
}
return msgParts()
})
return <Message message={props.message} parts={filteredParts()} />
}
export function SessionTurn(
props: ParentProps<{
sessionID: string
@@ -77,125 +102,119 @@ export function SessionTurn(
const data = useData()
const diffComponent = useDiffComponent()
const derived = createMemo(() => {
const allMessages = data.store.message[props.sessionID] ?? []
const userMessages = allMessages.filter((m) => m.role === "user").sort((a, b) => a.id.localeCompare(b.id))
const lastUserMessage = userMessages.at(-1)
const message = userMessages.find((m) => m.id === props.messageID)
const allMessages = createMemo(() => data.store.message[props.sessionID] ?? [])
const userMessages = createMemo(() =>
allMessages()
.filter((m) => m.role === "user")
.sort((a, b) => a.id.localeCompare(b.id)),
)
if (!message) {
return {
message: undefined,
parts: [] as PartType[],
assistantMessages: [] as AssistantMessage[],
assistantParts: [] as PartType[],
lastAssistantMessage: undefined as AssistantMessage | undefined,
lastTextPart: undefined as PartType | undefined,
error: undefined,
hasSteps: false,
isShellMode: false,
rawStatus: undefined as string | undefined,
isLastUserMessage: false,
}
}
const message = createMemo(() => userMessages().find((m) => m.id === props.messageID))
const isLastUserMessage = createMemo(() => message()?.id === userMessages().at(-1)?.id)
const parts = data.store.part[message.id] ?? []
const assistantMessages = allMessages.filter(
(m) => m.role === "assistant" && m.parentID === message.id,
) as AssistantMessage[]
const assistantParts: PartType[] = []
for (const m of assistantMessages) {
const msgParts = data.store.part[m.id]
if (msgParts) {
for (const p of msgParts) {
if (p) assistantParts.push(p)
}
}
}
const lastAssistantMessage = assistantMessages.at(-1)
const error = assistantMessages.find((m) => m.error)?.error
let lastTextPart: PartType | undefined
for (let i = assistantParts.length - 1; i >= 0; i--) {
if (assistantParts[i]?.type === "text") {
lastTextPart = assistantParts[i]
break
}
}
const hasSteps = assistantParts.some((p) => p?.type === "tool")
let isShellMode = false
if (parts.every((p) => p?.type === "text" && p?.synthetic) && assistantParts.length === 1) {
const assistantPart = assistantParts[0]
if (assistantPart?.type === "tool" && assistantPart?.tool === "bash") {
isShellMode = true
}
}
let resolvedParts = assistantParts
const currentTask = assistantParts.findLast(
(p) =>
p &&
p.type === "tool" &&
p.tool === "task" &&
p.state &&
"metadata" in p.state &&
p.state.metadata &&
p.state.metadata.sessionId &&
p.state.status === "running",
) as ToolPart | undefined
if (currentTask?.state && "metadata" in currentTask.state && currentTask.state.metadata?.sessionId) {
const taskMessages = data.store.message[currentTask.state.metadata.sessionId as string]?.filter(
(m) => m.role === "assistant",
)
if (taskMessages) {
const taskParts: PartType[] = []
for (const m of taskMessages) {
const msgParts = data.store.part[m.id]
if (msgParts) {
for (const p of msgParts) {
if (p) taskParts.push(p)
}
}
}
if (taskParts.length > 0) {
resolvedParts = taskParts
}
}
}
const lastPart = resolvedParts.at(-1)
const rawStatus = computeStatusFromPart(lastPart)
return {
message,
parts,
assistantMessages,
assistantParts,
lastAssistantMessage,
lastTextPart,
error,
hasSteps,
isShellMode,
rawStatus,
isLastUserMessage: message.id === lastUserMessage?.id,
}
const parts = createMemo(() => {
const msg = message()
if (!msg) return []
return data.store.part[msg.id] ?? []
})
const message = () => derived().message
const parts = () => derived().parts
const assistantMessages = () => derived().assistantMessages
const assistantParts = () => derived().assistantParts
const lastAssistantMessage = () => derived().lastAssistantMessage
const lastTextPart = () => derived().lastTextPart
const error = () => derived().error
const hasSteps = () => derived().hasSteps
const isShellMode = () => derived().isShellMode
const rawStatus = () => derived().rawStatus
const assistantMessages = createMemo(() => {
const msg = message()
if (!msg) return [] as AssistantMessage[]
return allMessages().filter((m) => m.role === "assistant" && m.parentID === msg.id) as AssistantMessage[]
})
const lastAssistantMessage = createMemo(() => assistantMessages().at(-1))
const error = createMemo(() => assistantMessages().find((m) => m.error)?.error)
const lastTextPart = createMemo(() => {
const msgs = assistantMessages()
for (let mi = msgs.length - 1; mi >= 0; mi--) {
const msgParts = data.store.part[msgs[mi].id] ?? []
for (let pi = msgParts.length - 1; pi >= 0; pi--) {
const part = msgParts[pi]
if (part?.type === "text") return part as TextPart
}
}
return undefined
})
const hasSteps = createMemo(() => {
for (const m of assistantMessages()) {
const msgParts = data.store.part[m.id]
if (!msgParts) continue
for (const p of msgParts) {
if (p?.type === "tool") return true
}
}
return false
})
const shellModePart = createMemo(() => {
const p = parts()
if (!p.every((part) => part?.type === "text" && part?.synthetic)) return
const msgs = assistantMessages()
if (msgs.length !== 1) return
const msgParts = data.store.part[msgs[0].id] ?? []
if (msgParts.length !== 1) return
const assistantPart = msgParts[0]
if (assistantPart?.type === "tool" && assistantPart.tool === "bash") return assistantPart
})
const isShellMode = createMemo(() => !!shellModePart())
const rawStatus = createMemo(() => {
const msgs = assistantMessages()
let last: PartType | undefined
let currentTask: ToolPart | undefined
for (let mi = msgs.length - 1; mi >= 0; mi--) {
const msgParts = data.store.part[msgs[mi].id] ?? []
for (let pi = msgParts.length - 1; pi >= 0; pi--) {
const part = msgParts[pi]
if (!part) continue
if (!last) last = part
if (
part.type === "tool" &&
part.tool === "task" &&
part.state &&
"metadata" in part.state &&
part.state.metadata?.sessionId &&
part.state.status === "running"
) {
currentTask = part as ToolPart
break
}
}
if (currentTask) break
}
const taskSessionId =
currentTask?.state && "metadata" in currentTask.state
? (currentTask.state.metadata?.sessionId as string | undefined)
: undefined
if (taskSessionId) {
const taskMessages = data.store.message[taskSessionId] ?? []
for (let mi = taskMessages.length - 1; mi >= 0; mi--) {
const msg = taskMessages[mi]
if (!msg || msg.role !== "assistant") continue
const msgParts = data.store.part[msg.id] ?? []
for (let pi = msgParts.length - 1; pi >= 0; pi--) {
const part = msgParts[pi]
if (part) return computeStatusFromPart(part)
}
}
}
return computeStatusFromPart(last)
})
const status = createMemo(
() =>
@@ -203,7 +222,7 @@ export function SessionTurn(
type: "idle",
},
)
const working = createMemo(() => status().type !== "idle" && derived().isLastUserMessage)
const working = createMemo(() => status().type !== "idle" && isLastUserMessage())
const retry = createMemo(() => {
const s = status()
if (s.type !== "retry") return
@@ -246,6 +265,7 @@ export function SessionTurn(
retrySeconds: 0,
status: rawStatus(),
duration: duration(),
summaryWaitTimedOut: false,
})
createEffect(() => {
@@ -286,6 +306,42 @@ export function SessionTurn(
onCleanup(() => clearInterval(timer))
})
createEffect(() => {
if (working()) {
setStore("summaryWaitTimedOut", false)
}
})
createEffect(() => {
if (working() || !isLastUserMessage()) return
const diffs = message()?.summary?.diffs
if (!diffs?.length) return
if (summary()) return
if (store.summaryWaitTimedOut) return
const timer = setTimeout(() => {
setStore("summaryWaitTimedOut", true)
}, 6000)
onCleanup(() => clearTimeout(timer))
})
const waitingForSummary = createMemo(() => {
if (!isLastUserMessage()) return false
if (working()) return false
const diffs = message()?.summary?.diffs
if (!diffs?.length) return false
if (summary()) return false
return !store.summaryWaitTimedOut
})
const showSummarySection = createMemo(() => {
if (working()) return false
return !waitingForSummary()
})
let lastStatusChange = Date.now()
let statusTimeout: number | undefined
createEffect(() => {
@@ -330,7 +386,7 @@ export function SessionTurn(
>
<Switch>
<Match when={isShellMode()}>
<Part part={assistantParts()[0]} message={msg()} defaultOpen />
<Part part={shellModePart()!} message={msg()} defaultOpen />
</Match>
<Match when={true}>
{/* Title (sticky) */}
@@ -362,7 +418,7 @@ export function SessionTurn(
size="small"
onClick={props.onStepsExpandedToggle ?? (() => {})}
>
<Show when={working()}>
<Show when={working() || waitingForSummary()}>
<Spinner />
</Show>
<Switch>
@@ -379,6 +435,7 @@ export function SessionTurn(
</span>
<span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span>
</Match>
<Match when={waitingForSummary()}>Generating summary</Match>
<Match when={working()}>{store.status ?? "Considering next steps"}</Match>
<Match when={props.stepsExpanded}>Hide steps</Match>
<Match when={!props.stepsExpanded}>Show steps</Match>
@@ -395,27 +452,15 @@ export function SessionTurn(
<Show when={props.stepsExpanded && assistantMessages().length > 0}>
<div data-slot="session-turn-collapsible-content-inner">
<For each={assistantMessages()}>
{(assistantMessage) => {
const parts = createMemo(() => data.store.part[assistantMessage.id] ?? [])
const last = createMemo(() =>
parts()
.filter((p) => p?.type === "text")
.at(-1),
)
return (
<Switch>
<Match when={!summary() && response() && lastTextPart()?.id === last()?.id}>
<Message
message={assistantMessage}
parts={parts().filter((p) => p?.id !== last()?.id)}
/>
</Match>
<Match when={true}>
<Message message={assistantMessage} parts={parts()} />
</Match>
</Switch>
)
}}
{(assistantMessage) => (
<AssistantMessageItem
message={assistantMessage}
summary={summary()}
response={response()}
lastTextPartId={lastTextPart()?.id}
working={working()}
/>
)}
</For>
<Show when={error()}>
<Card variant="error" class="error-card">
@@ -425,7 +470,7 @@ export function SessionTurn(
</div>
</Show>
{/* Summary */}
<Show when={!working()}>
<Show when={showSummarySection()}>
<div data-slot="session-turn-summary-section">
<div data-slot="session-turn-summary-header">
<Switch>
+104 -85
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,93 +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)
const targetHeight = scrollRef.scrollHeight
scrollRef.scrollTo({ top: targetHeight, behavior: "smooth" })
// Wait for scroll to complete before clearing autoScrolled
const checkScrollComplete = () => {
if (!scrollRef) {
setStore("autoScrolled", false)
return
}
const atBottom = scrollRef.scrollTop + scrollRef.clientHeight >= scrollRef.scrollHeight - 10
const reachedTarget = scrollRef.scrollTop >= targetHeight - scrollRef.clientHeight - 10
if (atBottom || reachedTarget) {
batch(() => {
setStore("lastScrollTop", scrollRef?.scrollTop ?? 0)
setStore("lastScrollHeight", scrollRef?.scrollHeight ?? 0)
setStore("autoScrolled", false)
})
} else {
requestAnimationFrame(checkScrollComplete)
}
}
requestAnimationFrame(checkScrollComplete)
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() {
@@ -107,36 +76,86 @@ export function createAutoScroll(options: AutoScrollOptions) {
}
}
function handleWheel(e: WheelEvent) {
if (e.deltaY < 0 && !store.userScrolled && options.working()) {
setStore("userScrolled", true)
options.onUserInteracted?.()
}
}
function handleTouchStart() {
if (!store.userScrolled && options.working()) {
setStore("userScrolled", true)
options.onUserInteracted?.()
}
}
function handleKeyDown(e: KeyboardEvent) {
if (["ArrowUp", "PageUp", "Home"].includes(e.key)) {
if (!store.userScrolled && options.working()) {
setStore("userScrolled", true)
options.onUserInteracted?.()
}
}
}
function handleMouseDown() {
isMouseDown = true
window.addEventListener("mouseup", handleMouseUp)
}
function handleMouseUp() {
isMouseDown = false
window.removeEventListener("mouseup", handleMouseUp)
}
// Reset userScrolled when work completes
createEffect(() => {
if (!options.working()) setStore("userScrolled", false)
if (!options.working()) {
setStore("userScrolled", false)
}
})
// Handle content resize
createResizeObserver(
() => store.contentRef,
({ width }) => {
const widthChanged = Math.abs(width - store.lastContentWidth) > 5
if (widthChanged && store.lastContentWidth > 0) {
setStore("reflowing", true)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setStore("reflowing", false)
if (options.working() && !store.userScrolled) {
scrollToBottom()
}
})
})
} else if (!store.reflowing) {
() => {
if (options.working() && !store.userScrolled) {
scrollToBottom()
}
setStore("lastContentWidth", width)
},
)
onCleanup(() => {
if (autoScrollTimeout) clearTimeout(autoScrollTimeout)
if (cleanupListeners) cleanupListeners()
})
return {
scrollRef: (el: HTMLElement | undefined) => {
if (cleanupListeners) {
cleanupListeners()
cleanupListeners = undefined
}
scrollRef = el
if (el) {
lastScrollTop = el.scrollTop
el.style.overflowAnchor = "none"
el.addEventListener("wheel", handleWheel, { passive: true })
el.addEventListener("touchstart", handleTouchStart, { passive: true })
el.addEventListener("keydown", handleKeyDown)
el.addEventListener("mousedown", handleMouseDown)
cleanupListeners = () => {
el.removeEventListener("wheel", handleWheel)
el.removeEventListener("touchstart", handleTouchStart)
el.removeEventListener("keydown", handleKeyDown)
el.removeEventListener("mousedown", handleMouseDown)
window.removeEventListener("mouseup", handleMouseUp)
}
}
},
contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el),
handleScroll,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.0.194",
"version": "1.0.200",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/web",
"type": "module",
"version": "1.0.194",
"version": "1.0.200",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -42,7 +42,7 @@ OpenCode comes with two built-in subagents, **General** and **Explore**. We'll l
## Built-in
OpenCode comes with two built-in primary agents and one built-in subagent.
OpenCode comes with two built-in primary agents and two built-in subagents.
---
+224
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.
+16 -6
View File
@@ -28,11 +28,13 @@ OpenCode supports both **JSON** and **JSONC** (JSON with Comments) formats.
You can place your config in a couple of different locations and they have a
different order of precedence.
:::note[Config Merging]
Configuration files are **merged together**, not replaced. Settings from all config locations are combined using a deep merge strategy, where later configs override earlier ones only for conflicting keys. Non-conflicting settings from all configs are preserved.
:::note
Configuration files are **merged together**, not replaced.
:::
Configuration files are merged together, not replaced. Settings from the following config locations are combined. Where later configs override earlier ones only for conflicting keys. Non-conflicting settings from all configs are preserved.
For example, if your global config sets `theme: "opencode"` and `autoupdate: true`, and your project config sets `model: "anthropic/claude-sonnet-4-5"`, the final configuration will include all three settings.
:::
---
@@ -58,13 +60,15 @@ This is also safe to be checked into Git and uses the same schema as the global
### Custom path
You can also specify a custom config file path using the `OPENCODE_CONFIG` environment variable. Settings from this config are merged with and can override the global and project configs.
You can also specify a custom config file path using the `OPENCODE_CONFIG` environment variable.
```bash
export OPENCODE_CONFIG=/path/to/my/custom-config.json
opencode run "Hello world"
```
Settings from this config are merged with and **can override** the global and project configs.
---
### Custom directory
@@ -79,7 +83,7 @@ export OPENCODE_CONFIG_DIR=/path/to/my/config-directory
opencode run "Hello world"
```
Note: The custom directory is loaded after the global config and `.opencode` directories, so it can override their settings.
The custom directory is loaded after the global config and `.opencode` directories, so it **can override** their settings.
---
@@ -376,6 +380,10 @@ You can disable providers that are loaded automatically through the `disabled_pr
}
```
:::note
The `disabled_providers` takes priority over `enabled_providers`.
:::
The `disabled_providers` option accepts an array of provider IDs. When a provider is disabled:
- It won't be loaded even if environment variables are set.
@@ -398,9 +406,11 @@ You can specify an allowlist of providers through the `enabled_providers` option
This is useful when you want to restrict OpenCode to only use specific providers rather than disabling them one by one.
:::note
If a provider appears in both `enabled_providers` and `disabled_providers`, the `disabled_providers` takes priority for backwards compatibility.
The `disabled_providers` takes priority over `enabled_providers`.
:::
If a provider appears in both `enabled_providers` and `disabled_providers`, the `disabled_providers` takes priority for backwards compatibility.
---
## Variables
+100 -61
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.
```
+2 -2
View File
@@ -14,7 +14,7 @@ Create one folder per skill name and put a `SKILL.md` inside it.
OpenCode searches these locations:
- Project config: `.opencode/skill/<name>/SKILL.md`
- Global config: `~/.opencode/skill/<name>/SKILL.md`
- Global config: `~/.config/opencode/skill/<name>/SKILL.md`
- Claude-compatible: `.claude/skills/<name>/SKILL.md`
---
@@ -24,7 +24,7 @@ OpenCode searches these locations:
For project-local paths, OpenCode walks up from your current working directory until it reaches the git worktree.
It loads any matching `skill/*/SKILL.md` in `.opencode/` and any matching `.claude/skills/*/SKILL.md` along the way.
Global definitions are also loaded from `~/.opencode/skill/*/SKILL.md`.
Global definitions are also loaded from `~/.config/opencode/skill/*/SKILL.md`.
---
+4 -2
View File
@@ -362,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:
+3 -3
View File
@@ -9,12 +9,12 @@ const notes = [] as string[]
console.log("=== publishing ===\n")
if (!Script.preview) {
const previous = await fetch("https://registry.npmjs.org/opencode-ai/latest")
const previous = await fetch("https://api.github.com/repos/sst/opencode/releases/latest")
.then((res) => {
if (!res.ok) throw new Error(res.statusText)
return res.json()
})
.then((data: any) => data.version)
.then((data: any) => data.tag_name.replace(/^v/, ""))
const log =
await $`git log v${previous}..HEAD --oneline --format="%h %s" -- packages/opencode packages/sdk packages/plugin packages/desktop packages/app`.text()
@@ -47,7 +47,7 @@ if (!Script.preview) {
body: {
model: {
providerID: "opencode",
modelID: "gemini-3-flash",
modelID: "claude-sonnet-4-5",
},
parts: [
{
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.0.194",
"version": "1.0.200",
"publisher": "sst-dev",
"repository": {
"type": "git",