Compare commits

...
88 changed files with 1528 additions and 1121 deletions
-1
View File
@@ -27,7 +27,6 @@ jobs:
working-directory: packages/www
run: bun run build
env:
BLUME_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
CLOUDFLARE_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
- name: Deploy
+2 -2
View File
@@ -91,7 +91,7 @@ jobs:
- uses: ./.github/actions/setup-bun
with:
bun-version: canary # Bun 1.4 until its stable release is published
bun-version: 1.4.0
- name: Setup git committer
id: committer
@@ -113,7 +113,7 @@ jobs:
id: build
run: ./packages/cli/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }}
env:
BUN_COMPILE_RELEASE: canary
BUN_COMPILE_RELEASE: bun-v1.4.0
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
GH_REPO: ${{ needs.version.outputs.repo }}
+31 -568
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-V1G3RuuwtlVx166oDVCqQ9v7ELWRr5JDYitcCMitbNA=",
"aarch64-linux": "sha256-nm/N60X7kdkNIa2oCgqyNp9ewrBrWvr6ES0qhxzypQc=",
"aarch64-darwin": "sha256-7UQtp4KrtxuT6MMrz7YYcJcF9/b5ECobkSwzI+yLe0Q=",
"x86_64-darwin": "sha256-jrn0GOa7qDskowio97bvhl7rsideXqHVbygSi3M1cf0="
"x86_64-linux": "sha256-tvhHO7NdDnBWtyaOj+kVX0Tzcv3O0uISbHC4V71kA0M=",
"aarch64-linux": "sha256-x3F43TL7BisEuXlJw7QS/DJoSzZWuNxbgn7hFCsTdXU=",
"aarch64-darwin": "sha256-y2r5Qy/XNgnvuzpnGMtwV5ZmhksUN2AUPLjbb40HYIE=",
"x86_64-darwin": "sha256-TS68JE40IaEa7ny0ATPUnEj8EV1CKtnGTPpyvZFwO7A="
}
}
+3
View File
@@ -1101,6 +1101,9 @@ export function createData(config: CreateDataInput) {
get(sessionID: string) {
return store.session.info[sessionID]
},
creating(sessionID: string) {
return creating.has(sessionID)
},
remember(info: SessionInfo) {
setStore("session", "info", info.id, reconcile(info))
sync.complete(`session:${info.id}`)
+32 -1
View File
@@ -1,4 +1,4 @@
import { test } from "bun:test"
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
@@ -72,6 +72,37 @@ test("revalidates after an event overtakes an active session read", async () =>
}
})
test("reports optimistic sessions as creating until the request settles", async () => {
const release = Promise.withResolvers<void>()
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
if (!request.url.endsWith("/api/session")) throw new Error(`Unexpected request: ${request.url}`)
await release.promise
return Response.json({ data: session(0) })
},
})
const event: CreateDataInput["event"] = {
on: () => () => {},
listen: () => () => {},
}
const setup = createRoot((dispose) => ({
data: createData({ api: () => api, directory: "/project", event, connection: { status: () => "connected" } }),
dispose,
}))
try {
const created = setup.data.session.create({ id: "ses_refresh", location: { directory: "/project" } })
expect(setup.data.session.creating(created.id)).toBe(true)
release.resolve()
await created.request
expect(setup.data.session.creating(created.id)).toBe(false)
} finally {
setup.dispose()
}
})
async function wait(check: () => boolean) {
const started = Date.now()
while (!check()) {
+74
View File
@@ -0,0 +1,74 @@
export * as LocationActivity from "./location-activity.js"
import { Clock, Context, Duration, Effect, Layer, RcMap, Schema } from "effect"
import { Bus } from "./bus.js"
import { Location } from "./location.js"
import { LocationServiceMap } from "./location-service-map.js"
import { SessionEvent } from "./session/event.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
const isSessionEvent = Schema.is(SessionEvent.Durable)
export class Service extends Context.Service<Service, {}>()("@opencode/LocationActivity") {}
export function layer(options: { readonly timeToLive?: Duration.Input; readonly sweepInterval?: Duration.Input } = {}) {
return Layer.effect(
Service,
Effect.gen(function* () {
const clock = yield* Clock.Clock
const bus = yield* Bus.Service
const locations = yield* LocationServiceMap.Service
const timeToLive = Duration.toMillis(options.timeToLive ?? "60 minutes")
const entries = new Map<string, { readonly ref: Location.Ref; expiresAt: number }>()
const key = (ref: Location.Ref) => `${ref.directory}\0${ref.workspaceID ?? ""}`
const touch = (ref: Location.Ref) =>
Effect.sync(() => {
entries.set(key(ref), { ref, expiresAt: clock.currentTimeMillisUnsafe() + timeToLive })
})
const unsubscribe = yield* bus.listen((event) => {
if (!isSessionEvent(event)) return Effect.void
const location = event.location
if (!location) return Effect.void
return RcMap.has(locations.rcMap, location).pipe(
Effect.flatMap((active) => (active ? touch(location) : Effect.void)),
)
})
yield* Effect.addFinalizer(() => unsubscribe)
yield* Effect.gen(function* () {
yield* Effect.sleep(options.sweepInterval ?? "1 minute")
const refs = Array.from(yield* RcMap.keys(locations.rcMap))
const cached = new Set(refs.map(key))
yield* Effect.forEach(
refs,
(ref) => (entries.has(key(ref)) ? Effect.void : touch(ref)),
{ discard: true },
)
for (const id of entries.keys()) {
if (!cached.has(id)) entries.delete(id)
}
const now = clock.currentTimeMillisUnsafe()
const expired = Array.from(entries.values()).filter((entry) => entry.expiresAt <= now)
yield* Effect.forEach(
expired,
(entry) => {
entries.delete(key(entry.ref))
return Effect.logInfo("location services evicted", {
directory: entry.ref.directory,
workspaceID: entry.ref.workspaceID,
}).pipe(Effect.andThen(locations.invalidate(entry.ref)))
},
{ discard: true },
)
}).pipe(Effect.forever, Effect.forkScoped)
return Service.of({})
}),
)
}
export const node = makeGlobalNode({
service: Service,
layer: layer(),
deps: [Bus.node, LocationServiceMap.node],
})
+2 -2
View File
@@ -1,4 +1,4 @@
import { Effect, Layer, LayerMap } from "effect"
import { Duration, Effect, Layer, LayerMap } from "effect"
import { existsSync } from "fs"
import path from "path"
import { Agent } from "./agent.js"
@@ -147,7 +147,7 @@ export function buildLocationServiceMap(
Layer.provide(LayerNode.compile(location.hoisted)),
)
},
{ idleTimeToLive: (ref) => (existsSync(ref.directory) ? "60 minutes" : 0) },
{ idleTimeToLive: (ref) => (existsSync(ref.directory) ? Duration.infinity : Duration.zero) },
),
(inner) => ({
...inner,
+1 -1
View File
@@ -130,7 +130,7 @@ secret into configuration.
For any request to migrate OpenCode configuration, agents, commands, skills,
plugins, integrations, or other behavior from V1 to V2, read the full
[migration guide](https://opencode.ai/v2/docs/migrate-v1) before acting. In
the repository, its source is `packages/www/content/docs/migrate-v1.mdx`.
the repository, its source is `packages/www/src/docs/content/migrate-v1.mdx`.
V1 config files and `.opencode/` definitions are intended to remain compatible.
The only intentional breaking changes are the server API and plugin API. Native
+52 -2
View File
@@ -3,14 +3,16 @@ import path from "path"
import { describe, expect } from "bun:test"
import { Config } from "@opencode-ai/schema/config"
import { Money } from "@opencode-ai/schema/money"
import { DateTime, Deferred, Effect, Equal, Fiber, Hash, RcMap, Schema, Stream } from "effect"
import { DateTime, Deferred, Duration, Effect, Equal, Fiber, Hash, Layer, LayerMap, RcMap, Schema, Stream } from "effect"
import { TestClock } from "effect/testing"
import { Plugin as EffectPlugin } from "@opencode-ai/plugin/effect"
import { Agent } from "@opencode-ai/core/agent"
import { Catalog } from "@opencode-ai/core/catalog"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { LocationServiceMap } from "@opencode-ai/core/location-services"
import { LocationServiceMap, type LocationServices } from "@opencode-ai/core/location-services"
import { LocationActivity } from "@opencode-ai/core/location-activity"
import { Location } from "@opencode-ai/core/location"
import { Plugin } from "@opencode-ai/core/plugin"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
@@ -21,6 +23,7 @@ import { Project } from "@opencode-ai/core/project"
import { Provider } from "@opencode-ai/core/provider"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionEvent } from "@opencode-ai/core/session/event"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { tmpdir } from "./fixture/tmpdir"
import { tempGlobalLayer } from "./fixture/global"
@@ -41,8 +44,55 @@ const itWithSdk = testEffect(
[Global.node, tempGlobalLayer],
]),
)
const activityLocations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
(ref) =>
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
Layer.succeed(
Location.Service,
Location.Service.of({
directory: ref.directory,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: ref.directory, canonical: ref.directory },
}),
) as unknown as Layer.Layer<LocationServices>,
{ idleTimeToLive: Duration.infinity },
),
)
const itWithActivity = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, LocationServiceMap.node, LocationActivity.node]),
[[LocationServiceMap.node, activityLocations]],
),
)
describe("LocationServiceMap", () => {
itWithActivity.effect("refreshes lifetime from Session events only", () =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const bus = yield* Bus.Service
const ref = Location.Ref.make({ directory: AbsolutePath.make("/project") })
const sessionID = Session.ID.make("ses_location_activity")
const read = Location.Service.pipe(Effect.provide(locations.get(ref)), Effect.scoped)
yield* read
yield* TestClock.adjust("59 minutes")
yield* bus.publish(Catalog.Event.Updated, {}, { location: ref })
yield* TestClock.adjust("2 minutes")
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
yield* read
yield* bus.publish(SessionEvent.Execution.Started, { sessionID }, { location: ref })
yield* TestClock.adjust("59 minutes")
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID }, { location: ref })
yield* TestClock.adjust("1 minute")
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([ref])
yield* TestClock.adjust("59 minutes")
expect(Array.from(yield* RcMap.keys(locations.rcMap))).toEqual([])
}),
)
it.live("retries a location after its missing directory is recreated", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
+2
View File
@@ -20,6 +20,7 @@ import { MCP } from "@opencode-ai/core/mcp/index"
import { Global } from "@opencode-ai/util/global"
import { InstructionDiscovery } from "@opencode-ai/core/instruction-discovery"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import { LocationActivity } from "@opencode-ai/core/location-activity"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
@@ -62,6 +63,7 @@ const applicationServiceNodes = [
WellKnown.node,
PtyEnvironment.node,
LocationServiceMap.node,
LocationActivity.node,
SessionRestart.node,
] as const
const applicationServices = LayerNode.group(applicationServiceNodes)
+1
View File
@@ -486,6 +486,7 @@ function App(props: { pair?: DialogPairCredentials }) {
if (route.data.type !== "session") return
const session = data.session.get(route.data.sessionID)
if (!session) return
if (data.session.creating(session.id)) return
if (session.location.workspaceID !== undefined || terminalEnvironment.variables === undefined) return
void client.api.session
.environment({ sessionID: session.id, variables: terminalEnvironment.variables })
@@ -956,7 +956,7 @@ export function Autocomplete(props: {
fg={index === store.selected ? theme.text.action.primary.focused : theme.text.subdued}
wrapMode="none"
>
{" " + option().description?.trimStart()}
{" " + option().description?.replace(/\s+/g, " ").trim()}
</text>
</Show>
</box>
+5 -4
View File
@@ -29,8 +29,8 @@ import {
type TabsState = {
tabs: SessionTab[]
// Read only long enough to remove the former client-owned state from persisted tab files.
unread?: Record<string, unknown>
// Kept empty for rollback compatibility with clients that still read this field.
unread: Record<string, unknown>
}
type PersistedState = {
@@ -43,7 +43,7 @@ type ScrollAnchor = {
screenY: number
}
const empty = (): TabsState => ({ tabs: [] })
const empty = (): TabsState => ({ tabs: [], unread: {} })
// Deliberately after connect settles: the visible session's mount syncs win the first slots.
const TAB_PREFETCH_DELAY = 300
@@ -128,6 +128,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const sessionID = root(tab.sessionID)
return openSessionTab(tabs, { sessionID, title: title(sessionID, tab.title) })
}, []),
unread: {},
})
const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
const newTab = createMemo((open = false) => {
@@ -219,7 +220,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
update((draft) => {
const next = normalize(draft)
draft.tabs = next.tabs
delete draft.unread
draft.unread = next.unread
})
})
@@ -265,10 +265,10 @@ test("stores session tabs for the current working directory by default", async (
const file = path.join(setup.state, "test", "tui", "tabs.json")
await wait(() => Bun.file(file).size > 0)
const stored = await Bun.file(file).json()
expect(stored.global).toEqual({ tabs: [] })
expect(stored.global).toEqual({ tabs: [], unread: {} })
expect(Object.keys(stored.cwd)).toEqual([directory])
expect(stored.cwd[directory].tabs.map((tab: { sessionID: string }) => tab.sessionID)).toEqual(["first"])
expect(stored.cwd[directory]).not.toHaveProperty("unread")
expect(stored.cwd[directory].unread).toEqual({})
} finally {
await setup.destroy()
}
@@ -336,14 +336,14 @@ test("acknowledges viewed sessions even when tabs are disabled", async () => {
}
})
test("purges legacy persisted unread records", async () => {
test("empties legacy persisted unread records for rollback compatibility", async () => {
const setup = await renderSessionTabs("first", { persisted: ["first"] })
try {
const file = path.join(setup.state, "test", "tui", "tabs.json")
// Normalize rewrites the active scope; the legacy record must not survive it.
// Normalize rewrites the active scope; legacy values must not survive, but older clients require the field.
await wait(async () => {
const stored = await Bun.file(file).json()
return !("unread" in stored.cwd[directory])
return Object.keys(stored.cwd[directory].unread).length === 0
})
} finally {
await setup.destroy()
+2 -2
View File
@@ -1,4 +1,4 @@
.blume/
.astro/
.wrangler/
dist/
node_modules/
.blume-verify/
+8 -12
View File
@@ -1,22 +1,18 @@
# Website and documentation guide
# Website guide
## Structure
- This package owns the `opencode.ai` website.
- Add custom marketing routes as Astro files under `pages/`.
- The whole project is mounted at `/v2/` by `deployment.base`, and documentation lives under `content/docs/` at `/v2/docs` through `basePath` in `blume.config.ts`.
- Write documentation in MDX. Every page should have `title` and `description` frontmatter.
- Use parenthesized content folders for sidebar groups that must not add a URL segment. Keep ungrouped top-level pages directly under `content/docs/`.
- Put static files in `public/` and reference them with root-relative paths.
- The API reference is generated from `openapi.json`; do not duplicate endpoint documentation as hand-written MDX.
- Keep documentation aligned with the current packages.
- This is a standard Astro website with a fully owned frontend.
- Only `base` in `astro.config.ts` should know the deployment subpath. Use `import.meta.env.BASE_URL` everywhere else.
- The documentation feature is self-contained under `src/docs/` and renders through the thin route in `src/pages/docs/`.
- Do not add a documentation frontend framework; this package owns the UI directly.
- Keep the installer route dynamic and `public/openapi.json` generated from `packages/protocol/openapi.json`.
## Local development
- Run `bun dev` from this package and preview the site at `http://localhost:3000/v2/`.
- Verify both custom marketing routes and documentation routes after changing shared navigation or layout code.
- Run `bun dev` from this package and use the local URL printed by Astro.
## Validation
- Run `bun typecheck`, `bun validate`, and `bun run build` from this package after documentation or configuration changes.
- Run `bun typecheck`, `bun run check:generated`, and `bun run build` from this package after changes.
- Treat validation and build errors as blockers.
+8 -5
View File
@@ -1,10 +1,13 @@
# OpenCode website
The OpenCode V2 website, powered by Blume and deployed with Wrangler at `https://opencode.ai/v2/`. Blume mounts the documentation at `/v2/docs`, and `https://v2.opencode.ai` redirects to the same deployment.
The standard Astro website for OpenCode. The only deployment-specific path is the `base` setting in `astro.config.ts`; application code uses `import.meta.env.BASE_URL`.
Wrangler deploys the site through Blume's Cloudflare server adapter. Documentation pages are prerendered, while custom dynamic routes and endpoints can run in the Worker. Production uses `opencode-www` at `opencode.ai/v2/`; dev uses `opencode-www-dev` at `dev.opencode.ai/v2/`. The `v2.opencode.ai` alias is handled by a Cloudflare Redirect Rule outside this project.
- `src/pages/` owns website routes.
- `src/docs/` is the self-contained documentation feature rendered under `/docs`.
- `/install` proxies the current installer.
- `/openapi.json` serves the generated OpenAPI specification.
The `deploy-www` GitHub workflow deploys the `dev` branch to the dev Worker and the `v2` branch to the production Worker.
The deployment currently sets `base: "/v2"`. The `v2.opencode.ai` alias is handled by a Cloudflare Redirect Rule outside this project.
## Development
@@ -14,12 +17,12 @@ From this directory, run:
bun dev
```
The site opens at `http://localhost:3000/v2/`; documentation is available at `http://localhost:3000/v2/docs`.
The local URL includes the base configured in `astro.config.ts`.
## Verification
```bash
bun typecheck
bun validate
bun run check:generated
bun run build
```
+56
View File
@@ -0,0 +1,56 @@
import cloudflare from "@astrojs/cloudflare"
import mdx from "@astrojs/mdx"
import { unified } from "@astrojs/markdown-remark"
import { defineConfig } from "astro/config"
import remarkDocsLinks from "./src/docs/remark-links"
const base = "/v2/"
export default defineConfig({
site: process.env.CLOUDFLARE_ENV === "production" ? "https://opencode.ai" : "https://dev.opencode.ai",
base,
output: "server",
adapter: cloudflare({ imageService: "passthrough" }),
integrations: [mdx()],
markdown: {
processor: unified({ remarkPlugins: [[remarkDocsLinks, { base }]] }),
shikiConfig: {
themes: {
light: "github-light",
dark: "github-dark",
},
transformers: [
{
name: "code-block-title",
root(root) {
const title = this.options.meta?.__raw?.match(/title="([^"]+)"/)?.[1]
if (!title) return
const pre = root.children[0]
if (!pre || pre.type !== "element") return
root.children = [
{
type: "element",
tagName: "figure",
properties: { className: ["astro-code-figure"] },
children: [
{
type: "element",
tagName: "figcaption",
properties: { className: ["astro-code-title"] },
children: [{ type: "text", value: title }],
},
pre,
],
},
]
},
},
],
},
},
vite: {
server: {
allowedHosts: true,
},
},
})
-98
View File
@@ -1,98 +0,0 @@
import { defineConfig } from "blume"
export default defineConfig({
title: "OpenCode",
description: "The open source AI coding agent.",
basePath: "/docs",
logo: {
image: {
light: "/assets/logo-light.svg",
dark: "/assets/logo-dark.svg",
alt: "OpenCode",
},
text: "",
href: "/",
},
content: {
root: "content/docs",
},
github: {
owner: "anomalyco",
repo: "opencode",
branch: "v2",
dir: "packages/www",
},
theme: {
background: { dark: "#000000" },
fonts: {
body: {
name: "OpenTUI Mono",
fallback: "mono",
variants: [
{ src: "public/fonts/OpenTUIMono-Regular.woff2", weight: 400 },
{ src: "public/fonts/OpenTUIMono-Bold.woff2", weight: 700 },
{ src: "public/fonts/OpenTUIMono-Italic.woff2", weight: 400, style: "italic" },
{ src: "public/fonts/OpenTUIMono-BoldItalic.woff2", weight: 700, style: "italic" },
],
},
display: {
name: "OpenTUI Mono",
fallback: "mono",
variants: [
{ src: "public/fonts/OpenTUIMono-Regular.woff2", weight: 400 },
{ src: "public/fonts/OpenTUIMono-Bold.woff2", weight: 700 },
{ src: "public/fonts/OpenTUIMono-Italic.woff2", weight: 400, style: "italic" },
{ src: "public/fonts/OpenTUIMono-BoldItalic.woff2", weight: 700, style: "italic" },
],
},
mono: {
name: "OpenTUI Mono",
fallback: "mono",
variants: [
{ src: "public/fonts/OpenTUIMono-Regular.woff2", weight: 400 },
{ src: "public/fonts/OpenTUIMono-Bold.woff2", weight: 700 },
{ src: "public/fonts/OpenTUIMono-Italic.woff2", weight: 400, style: "italic" },
{ src: "public/fonts/OpenTUIMono-BoldItalic.woff2", weight: 700, style: "italic" },
],
},
},
mode: "dark",
},
navigation: {
tabs: [
{ label: "Docs", path: "/" },
{ label: "CLI", path: "/cli" },
{ label: "Build", path: "/build" },
{ label: "API", path: "/api" },
],
},
markdown: {
code: {
icons: false,
},
},
openapi: {
enabled: true,
route: "/api",
spec: "./openapi.json",
},
seo: {
og: {
fonts: [{ name: "IBM Plex Mono", weight: [400, 600] }],
logo: "public/assets/logo-dark.svg",
palette: {
accent: "#b7b1b1",
background: "#131010",
border: "#343030",
foreground: "#f1ecec",
muted: "#b7b1b1",
},
},
},
deployment: {
adapter: "cloudflare",
base: "/v2/",
output: "server",
site: process.env.BLUME_ENV === "dev" ? "https://dev.opencode.ai" : "https://opencode.ai",
},
})
-12
View File
@@ -1,12 +0,0 @@
import { defineComponents } from "blume"
import Breadcrumbs from "./components/Breadcrumbs.astro"
import ThemeTokens from "./snippets/generated/theme-tokens.mdx"
export default defineComponents({
layout: {
Breadcrumbs,
},
mdx: {
ThemeTokens,
},
})
@@ -1,2 +0,0 @@
---
---
@@ -1,24 +0,0 @@
import { defineMeta } from "blume"
export default defineMeta({
title: "Configure",
pages: [
"lsp",
"agents",
"models",
"skills",
"themes",
"commands",
"providers",
"snapshots",
"compaction",
"formatters",
"references",
"attachments",
"mcp-servers",
"permissions",
"instructions",
"sharing",
"warming",
],
})
-6
View File
@@ -1,6 +0,0 @@
import { defineMeta } from "blume"
export default defineMeta({
title: "Build",
pages: ["sdk", "index", "client", "plugins"],
})
@@ -1,6 +0,0 @@
import { defineMeta } from "blume"
export default defineMeta({
title: "Configure",
pages: ["theme", "plugins", "keybinds"],
})
-6
View File
@@ -1,6 +0,0 @@
import { defineMeta } from "blume"
export default defineMeta({
title: "Intro",
pages: ["index", "config", "configure", "providers"],
})
-5
View File
@@ -1,5 +0,0 @@
import { defineMeta } from "blume"
export default defineMeta({
pages: ["cli", "build", "index", "config", "configure", "migrate-v1", "troubleshooting"],
})
+12 -12
View File
@@ -4,25 +4,25 @@
"private": true,
"type": "module",
"scripts": {
"dev": "bun run generate && blume dev --host --port 3000",
"build": "bun run generate && blume build && bun script/prepare-cloudflare.ts",
"dev": "bun run generate && astro dev --host --port 3000",
"build": "bun run generate && astro build && bun script/pagefind.ts && bun run validate && bun script/prepare-cloudflare.ts",
"deploy": "wrangler deploy --config dist/server/wrangler.json",
"generate": "bun script/generate-theme-tokens.ts && bun script/generate-openapi.ts",
"check:generated": "bun script/generate-theme-tokens.ts --check && bun script/generate-openapi.ts --check",
"typecheck": "blume check",
"validate": "blume validate",
"doctor": "blume doctor"
"generate": "bun script/generate-openapi.ts",
"check:generated": "bun script/generate-openapi.ts --check",
"validate": "bun script/validate-links.ts",
"typecheck": "astro check"
},
"dependencies": {
"blume": "1.5.1"
"@astrojs/mdx": "7.0.5",
"astro": "7.1.3"
},
"devDependencies": {
"@astrojs/cloudflare": "14.1.4",
"@opencode-ai/theme": "workspace:*",
"@astrojs/check": "0.9.6",
"@astrojs/markdown-remark": "7.2.4",
"@types/bun": "catalog:",
"astro": "7.1.3",
"effect": "catalog:",
"prettier": "3.6.2",
"pagefind": "1.5.2",
"typescript": "catalog:",
"wrangler": "4.110.0"
},
"engines": {
-5
View File
@@ -1,5 +0,0 @@
---
export const prerender = false
return Astro.redirect(`${import.meta.env.BASE_URL}docs`, 308)
---
-5
View File
@@ -1,5 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 18H6V6H18V18Z" fill="#F5F5F5"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3 3H21V21H3V3ZM8 8V16H16V8H8Z" fill="#3B7DD8"/>
<path d="M16 8H20V12H16V8Z" fill="#FAB283"/>
</svg>

Before

Width:  |  Height:  |  Size: 297 B

@@ -1,136 +0,0 @@
#!/usr/bin/env bun
import { Schema, SchemaAST } from "effect"
import { format } from "prettier"
import { ThemeDefinition, ThemeDocument } from "@opencode-ai/theme/tui"
const target = import.meta.dir + "/../snippets/generated/theme-tokens.mdx"
const root = requireObject(ThemeDefinition.ast)
const hue = requireObject(requireField(root, "hue").type)
const hueNames = hue.propertySignatures.map((field) => String(field.name))
const hueSteps = requireObject(requireField(hue, hueNames[0]).type).propertySignatures.map((field) =>
String(field.name),
)
const contexts = root.propertySignatures
.map((field) => String(field.name))
.filter((name) => name.startsWith("@context:"))
const tokens = root.propertySignatures
.filter((field) => {
const name = String(field.name)
return name !== "hue" && name !== "categorical" && !name.startsWith("@context:")
})
.flatMap((field) => tokenPaths(field.type, String(field.name)))
const groups = Map.groupBy(tokens, (token) =>
token
.split(".")
.slice(0, token.split(".").length > 2 ? 2 : 1)
.join("."),
)
const table = [...groups]
.map(([group, values]) => `| \`${group}\` | ${values.map((value) => `\`${value}\``).join("<br />")} |`)
.join("\n")
const example = {
version: 2,
light: {
hue: {
accent: "$hue.purple",
interactive: "$hue.purple",
},
text: {
default: "$hue.neutral.900",
},
background: {
default: "#fafafa",
},
},
dark: {
mergeMode: true,
text: {
default: "$hue.neutral.100",
},
background: {
default: "#101014",
},
},
} satisfies ThemeDocument
Schema.decodeUnknownSync(ThemeDocument)(example)
const output = await format(
`{/* Generated by packages/www/script/generate-theme-tokens.ts. Do not edit. */}
\`\`\`json title="my-theme.json"
${JSON.stringify(example, null, 2)}
\`\`\`
## Token reference
This reference is generated from the Effect schema in
\`@opencode-ai/theme/tui\`. Changes to the runtime schema update
this section through \`bun run generate\`.
### Hue tokens
Every hue is a ${hueSteps.length}-step scale. Define a scale with all of these
steps, or alias it to another hue with a value such as \`$hue.blue\`.
| | Values |
| --- | --- |
| Hues | ${hueNames.map((name) => `\`${name}\``).join(", ")} |
| Steps | ${hueSteps.map((step) => `\`${step}\``).join(", ")} |
Reference a hue color as \`$hue.<name>.<step>\`, for example
\`$hue.interactive.500\`.
### Semantic tokens
Semantic values can reference another token by prefixing its path with \`$\`,
for example \`$text.default\`. Stateful tokens inherit their \`default\`
value when a state is omitted.
| Group | Tokens |
| --- | --- |
${table}
### Contexts
${contexts.map((context) => `\`${context}\``).join(" and ")} accept partial
overrides of the semantic tokens above. Components apply these contexts to
surfaces that need different contrast without changing the base theme.
`,
{ parser: "mdx", printWidth: 120, semi: false },
)
if (process.argv.includes("--check")) {
const current = await Bun.file(target).text()
if (current === output) process.exit(0)
console.error("Generated theme token documentation is stale. Run `bun run generate` from packages/www.")
process.exit(1)
}
await Bun.write(target, output)
function requireObject(ast: SchemaAST.AST): SchemaAST.Objects {
if (SchemaAST.isObjects(ast)) return ast
if (SchemaAST.isUnion(ast)) {
const object = ast.types.map(findObject).find((value) => value !== undefined)
if (object) return object
}
throw new Error(`Expected an object schema, received ${ast._tag}`)
}
function findObject(ast: SchemaAST.AST): SchemaAST.Objects | undefined {
if (SchemaAST.isObjects(ast)) return ast
if (SchemaAST.isUnion(ast)) return ast.types.map(findObject).find((value) => value !== undefined)
if (SchemaAST.isSuspend(ast)) return findObject(ast.thunk())
}
function requireField(ast: SchemaAST.Objects, name: string) {
const field = ast.propertySignatures.find((field) => String(field.name) === name)
if (field) return field
throw new Error(`Theme schema field not found: ${name}`)
}
function tokenPaths(ast: SchemaAST.AST, prefix: string): string[] {
const object = findObject(ast)
if (!object || object.propertySignatures.length === 0) return [prefix]
return object.propertySignatures.flatMap((field) => tokenPaths(field.type, `${prefix}.${String(field.name)}`))
}
+5
View File
@@ -0,0 +1,5 @@
import config from "../astro.config"
const base = config.base?.replace(/\/$/, "") ?? ""
await Bun.$`pagefind --site ${`dist/client${base}/docs`}`
+39
View File
@@ -0,0 +1,39 @@
import path from "node:path"
const root = path.resolve(import.meta.dir, "../dist/client")
const files = await Array.fromAsync(new Bun.Glob("**/*.html").scan({ cwd: root, absolute: true }))
const failures = (
await Promise.all(
files.flatMap(async (file) => {
const html = await Bun.file(file).text()
const page = `https://opencode.local/${path.relative(root, file).replace(/index\.html$/, "")}`
return Promise.all(
Array.from(html.matchAll(/href="([^"]+)"/g), async (match) => {
const url = new URL(match[1], page)
if (url.origin !== "https://opencode.local") return
const targetPath = path.join(root, decodeURIComponent(url.pathname))
const target = (
await Promise.all(
[targetPath, path.join(targetPath, "index.html"), `${targetPath}.html`].map(async (candidate) =>
(await Bun.file(candidate).exists()) ? candidate : undefined,
),
)
).find((candidate) => candidate !== undefined)
if (!target) return `${path.relative(root, file)}: missing ${url.pathname}`
if (!url.hash || !target.endsWith(".html")) return
const id = decodeURIComponent(url.hash.slice(1))
if ((await Bun.file(target).text()).includes(`id="${id}"`)) return
return `${path.relative(root, file)}: missing ${url.pathname}${url.hash}`
}),
)
}),
)
)
.flat(2)
.filter((failure) => failure !== undefined)
if (failures.length === 0) process.exit(0)
console.error([...new Set(failures)].sort().join("\n"))
process.exit(1)
@@ -1,79 +0,0 @@
{/* Generated by packages/www/script/generate-theme-tokens.ts. Do not edit. */}
```json title="my-theme.json"
{
"version": 2,
"light": {
"hue": {
"accent": "$hue.purple",
"interactive": "$hue.purple"
},
"text": {
"default": "$hue.neutral.900"
},
"background": {
"default": "#fafafa"
}
},
"dark": {
"mergeMode": true,
"text": {
"default": "$hue.neutral.100"
},
"background": {
"default": "#101014"
}
}
}
```
## Token reference
This reference is generated from the Effect schema in
`@opencode-ai/theme/tui`. Changes to the runtime schema update
this section through `bun run generate`.
### Hue tokens
Every hue is a 9-step scale. Define a scale with all of these
steps, or alias it to another hue with a value such as `$hue.blue`.
| | Values |
| ----- | -------------------------------------------------------------------------------------------------------- |
| Hues | `gray`, `red`, `orange`, `yellow`, `green`, `cyan`, `blue`, `purple`, `accent`, `interactive`, `neutral` |
| Steps | `100`, `200`, `300`, `400`, `500`, `600`, `700`, `800`, `900` |
Reference a hue color as `$hue.<name>.<step>`, for example
`$hue.interactive.500`.
### Semantic tokens
Semantic values can reference another token by prefixing its path with `$`,
for example `$text.default`. Stateful tokens inherit their `default`
value when a state is omitted.
| Group | Tokens |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `text` | `text.default`<br />`text.subdued` |
| `text.action` | `text.action.primary.default`<br />`text.action.primary.$hovered`<br />`text.action.primary.$focused`<br />`text.action.primary.$pressed`<br />`text.action.primary.$selected`<br />`text.action.primary.$disabled`<br />`text.action.secondary.default`<br />`text.action.secondary.$hovered`<br />`text.action.secondary.$focused`<br />`text.action.secondary.$pressed`<br />`text.action.secondary.$selected`<br />`text.action.secondary.$disabled`<br />`text.action.destructive.default`<br />`text.action.destructive.$hovered`<br />`text.action.destructive.$focused`<br />`text.action.destructive.$pressed`<br />`text.action.destructive.$selected`<br />`text.action.destructive.$disabled` |
| `text.formfield` | `text.formfield.default`<br />`text.formfield.$hovered`<br />`text.formfield.$focused`<br />`text.formfield.$pressed`<br />`text.formfield.$selected`<br />`text.formfield.$disabled` |
| `text.feedback` | `text.feedback.error.default`<br />`text.feedback.error.subdued`<br />`text.feedback.warning.default`<br />`text.feedback.warning.subdued`<br />`text.feedback.success.default`<br />`text.feedback.success.subdued`<br />`text.feedback.info.default`<br />`text.feedback.info.subdued` |
| `background` | `background.default` |
| `background.surface` | `background.surface.offset`<br />`background.surface.overlay` |
| `background.action` | `background.action.primary.default`<br />`background.action.primary.$hovered`<br />`background.action.primary.$focused`<br />`background.action.primary.$pressed`<br />`background.action.primary.$selected`<br />`background.action.primary.$disabled`<br />`background.action.secondary.default`<br />`background.action.secondary.$hovered`<br />`background.action.secondary.$focused`<br />`background.action.secondary.$pressed`<br />`background.action.secondary.$selected`<br />`background.action.secondary.$disabled`<br />`background.action.destructive.default`<br />`background.action.destructive.$hovered`<br />`background.action.destructive.$focused`<br />`background.action.destructive.$pressed`<br />`background.action.destructive.$selected`<br />`background.action.destructive.$disabled` |
| `background.formfield` | `background.formfield.default`<br />`background.formfield.$hovered`<br />`background.formfield.$focused`<br />`background.formfield.$pressed`<br />`background.formfield.$selected`<br />`background.formfield.$disabled` |
| `background.feedback` | `background.feedback.error.default`<br />`background.feedback.warning.default`<br />`background.feedback.success.default`<br />`background.feedback.info.default` |
| `border` | `border.default` |
| `scrollbar` | `scrollbar.default` |
| `diff.text` | `diff.text.added`<br />`diff.text.removed`<br />`diff.text.context`<br />`diff.text.hunkHeader` |
| `diff.background` | `diff.background.added`<br />`diff.background.removed`<br />`diff.background.context` |
| `diff.highlight` | `diff.highlight.added`<br />`diff.highlight.removed` |
| `diff.lineNumber` | `diff.lineNumber.text`<br />`diff.lineNumber.background.added`<br />`diff.lineNumber.background.removed` |
| `syntax` | `syntax.comment`<br />`syntax.keyword`<br />`syntax.function`<br />`syntax.variable`<br />`syntax.string`<br />`syntax.number`<br />`syntax.type`<br />`syntax.operator`<br />`syntax.punctuation` |
| `markdown` | `markdown.text`<br />`markdown.heading`<br />`markdown.link`<br />`markdown.linkText`<br />`markdown.code`<br />`markdown.blockQuote`<br />`markdown.emphasis`<br />`markdown.strong`<br />`markdown.horizontalRule`<br />`markdown.listItem`<br />`markdown.listEnumeration`<br />`markdown.image`<br />`markdown.imageText`<br />`markdown.codeBlock` |
### Contexts
`@context:elevated` and `@context:overlay` accept partial
overrides of the semantic tokens above. Components apply these contexts to
surfaces that need different contrast without changing the base theme.
+25
View File
@@ -0,0 +1,25 @@
import { defineCollection } from "astro:content"
import { glob } from "astro/loaders"
import { z } from "astro/zod"
export const collections = {
docs: defineCollection({
loader: glob({
base: "./src/docs/content",
pattern: "**/*.{md,mdx}",
}),
schema: z.object({
title: z.string(),
description: z.string().optional(),
tableOfContents: z
.union([
z.boolean(),
z.object({
minHeadingLevel: z.number().int().min(1).max(6).optional(),
maxHeadingLevel: z.number().int().min(1).max(6).optional(),
}),
])
.optional(),
}),
}),
}
+15
View File
@@ -0,0 +1,15 @@
# Documentation feature guide
## Structure
- This folder owns the documentation data model, content, frontend, and styling.
- Documentation content lives in `content/` and is loaded by `../content.config.ts`.
- `lib/navigation.ts` is the source of truth for header sections and sidebar ordering.
- The thin route entrypoints live in `../pages/docs/`; keep docs implementation details here.
- Do not add a documentation frontend framework; this folder owns the UI directly.
- Keep internal Markdown links docs-root-relative, for example `/config`; `remark-links.ts` applies the site and docs base paths.
## Validation
- Run `bun typecheck` and `bun run build` from `packages/www` after changes.
- Check desktop and mobile layouts when changing navigation or shared styles.
@@ -0,0 +1,14 @@
---
interface Props {
type?: "note" | "tip" | "warning"
title?: string
}
const type = Astro.props.type ?? "note"
const title = Astro.props.title ?? (type === "tip" ? "Tip" : type === "warning" ? "Warning" : "Note")
---
<aside class={`callout callout-${type}`} aria-label={title}>
<strong>{title}</strong>
<div><slot /></div>
</aside>
@@ -0,0 +1,15 @@
---
interface Props {
title: string
href: string
}
const href = Astro.props.href.startsWith("/")
? `${import.meta.env.BASE_URL}docs${Astro.props.href}`
: Astro.props.href
---
<a class="docs-card" href={href}>
<strong>{Astro.props.title}</strong>
<div><slot /></div>
</a>
@@ -0,0 +1,9 @@
---
interface Props {
cols?: number
}
---
<div class="docs-card-group" style={`--card-columns: ${Astro.props.cols ?? 2}`}>
<slot />
</div>
@@ -0,0 +1,20 @@
---
interface Props {
code: string
}
---
<div class="docs-code-block">
<pre><code>{Astro.props.code}</code></pre>
<button type="button" data-copy-code data-code={Astro.props.code}>Copy</button>
</div>
<script>
document.addEventListener("click", async (event) => {
const button = event.target
if (!(button instanceof HTMLButtonElement) || !button.matches("[data-copy-code]")) return
await navigator.clipboard.writeText(button.dataset.code ?? "")
button.textContent = "Copied"
window.setTimeout(() => (button.textContent = "Copy"), 1500)
})
</script>
@@ -0,0 +1,118 @@
---
import { docsHref, docsSections } from "../lib/navigation"
interface Props {
currentSlug: string
}
const currentSlug = Astro.props.currentSlug
const searchEnabled = import.meta.env.PROD
---
<header class="site-header">
<div class="header-inner">
<a class="brand" href={import.meta.env.BASE_URL} aria-label="OpenCode home">
<img src={`${import.meta.env.BASE_URL}assets/logo-dark.svg`} alt="OpenCode" />
</a>
<nav aria-label="Documentation sections">
{
docsSections.map((section) => (
<a href={docsHref(section.landingSlug)} aria-current={currentSlug === section.key || currentSlug.startsWith(`${section.key}/`) || (section.key === "docs" && !currentSlug.includes("/") && currentSlug !== "api") ? "page" : undefined}>
{section.title}
</a>
))
}
</nav>
<div class="header-spacer"></div>
{
searchEnabled && (
<button class="search-trigger" type="button" data-search-open aria-label="Search documentation">
<span>Search</span><kbd>⌘K</kbd>
</button>
)
}
<a class="github-link" href="https://github.com/anomalyco/opencode" aria-label="OpenCode on GitHub">GitHub</a>
</div>
</header>
{
searchEnabled && (
<dialog
class="search-dialog"
data-search-dialog
data-pagefind-path={`${import.meta.env.BASE_URL}docs/pagefind/pagefind.js`}
data-base-url={`${import.meta.env.BASE_URL}docs/`}
aria-label="Search documentation"
>
<form method="dialog" class="search-bar">
<span aria-hidden="true">&gt;</span>
<input data-search-input type="search" placeholder="Search docs" autocomplete="off" />
<button type="submit" aria-label="Close search">Esc</button>
</form>
<div class="search-results" data-search-results>
<p>Start typing to search the documentation.</p>
</div>
</dialog>
)
}
<script is:inline>
let pagefind
document.addEventListener("keydown", (event) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault()
const dialog = document.querySelector("[data-search-dialog]")
dialog?.showModal()
dialog?.querySelector("[data-search-input]")?.focus()
}
})
const setupSearch = () => {
const dialog = document.querySelector("[data-search-dialog]")
const input = document.querySelector("[data-search-input]")
const results = document.querySelector("[data-search-results]")
const trigger = document.querySelector("[data-search-open]")
if (trigger && !trigger.dataset.searchReady) {
trigger.dataset.searchReady = "true"
trigger.addEventListener("click", () => {
dialog?.showModal()
input?.focus()
})
}
if (input && results && !input.dataset.searchReady) input.addEventListener("input", async () => {
input.dataset.searchReady = "true"
const query = input.value.trim()
if (!query) {
results.innerHTML = "<p>Start typing to search the documentation.</p>"
return
}
const pagefindPath = dialog?.dataset.pagefindPath
if (!pagefindPath) return
const api = pagefind ?? (pagefind = await import(pagefindPath))
const search = await api.search(query)
const pages = await Promise.all(search.results.slice(0, 8).map((result) => result.data()))
results.replaceChildren(
...pages.map((page) => {
const link = document.createElement("a")
const base = dialog.dataset.baseUrl ?? "/"
link.href = page.url.startsWith(base)
? page.url
: `${dialog.dataset.baseUrl?.replace(/\/$/, "")}${page.url}`
const title = document.createElement("strong")
title.textContent = page.meta.title
const excerpt = document.createElement("span")
excerpt.innerHTML = page.excerpt
link.append(title, excerpt)
return link
}),
)
if (!pages.length) results.innerHTML = "<p>No results found.</p>"
})
}
setupSearch()
document.addEventListener("astro:page-load", setupSearch)
</script>
@@ -0,0 +1,27 @@
---
import { docsHref, type DocsNavGroup } from "../lib/navigation"
interface Props {
groups: DocsNavGroup[]
currentSlug: string
}
---
<nav class="docs-sidebar" aria-label="Documentation">
{
Astro.props.groups.map((group) => (
<section>
{group.title && <h2>{group.title}</h2>}
<ul>
{group.items.map((item) => (
<li>
<a href={docsHref(item.slug)} aria-current={item.slug === Astro.props.currentSlug ? "page" : undefined}>
{item.title}
</a>
</li>
))}
</ul>
</section>
))
}
</nav>
@@ -0,0 +1,24 @@
---
import type { MarkdownHeading } from "astro"
interface Props {
headings: MarkdownHeading[]
}
const headings = Astro.props.headings.filter((heading) => heading.depth === 2 || heading.depth === 3)
---
{
headings.length > 0 && (
<nav class="docs-toc" aria-label="On this page">
<h2>On this page</h2>
<ul>
{headings.map((heading) => (
<li class={heading.depth === 3 ? "nested" : undefined}>
<a href={`#${heading.slug}`}>{heading.text}</a>
</li>
))}
</ul>
</nav>
)
}
@@ -0,0 +1,73 @@
---
import type { MarkdownHeading } from "astro"
import { ClientRouter } from "astro:transitions"
import DocsHeader from "../components/DocsHeader.astro"
import DocsSidebar from "../components/DocsSidebar.astro"
import DocsTableOfContents from "../components/DocsTableOfContents.astro"
import { getDocsSection } from "../lib/navigation"
import "../styles/global.css"
interface Props {
title: string
description?: string
currentSlug: string
headings: MarkdownHeading[]
showTableOfContents?: boolean
}
const section = getDocsSection(Astro.props.currentSlug)
const description = Astro.props.description ?? `${Astro.props.title} documentation for OpenCode.`
const canonical = new URL(Astro.url.pathname, "https://opencode.ai").href
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content={description} />
<meta name="theme-color" content="#000000" />
<ClientRouter fallback="swap" />
<link rel="canonical" href={canonical} />
<link rel="icon" type="image/svg+xml" href={`${import.meta.env.BASE_URL}favicon.svg`} />
<title>{Astro.props.title} | OpenCode</title>
</head>
<body>
<a class="skip-link" href="#docs-content">Skip to content</a>
<DocsHeader currentSlug={Astro.props.currentSlug} />
<details class="mobile-navigation">
<summary>{section.title} navigation</summary>
<DocsSidebar groups={section.groups} currentSlug={Astro.props.currentSlug} />
</details>
<div class="docs-frame">
<aside class="sidebar-column">
<DocsSidebar groups={section.groups} currentSlug={Astro.props.currentSlug} />
</aside>
<main id="docs-content">
<article class="prose" data-pagefind-body data-pagefind-meta={`title:${Astro.props.title}`}>
<h1>{Astro.props.title}</h1>
<slot />
</article>
</main>
<aside class="toc-column">
{Astro.props.showTableOfContents !== false && <DocsTableOfContents headings={Astro.props.headings} />}
</aside>
</div>
<script is:inline>
const addHeadingAnchors = () => {
for (const heading of document.querySelectorAll(".prose :is(h2, h3)[id]")) {
if (heading.querySelector(".heading-anchor")) continue
const anchor = document.createElement("a")
anchor.className = "heading-anchor"
anchor.href = `#${heading.id}`
anchor.setAttribute("aria-label", `Link to ${heading.textContent ?? "section"}`)
anchor.textContent = "#"
heading.append(anchor)
}
}
addHeadingAnchors()
document.addEventListener("astro:page-load", addHeadingAnchors)
</script>
</body>
</html>
+124
View File
@@ -0,0 +1,124 @@
export interface DocsNavItem {
title: string
slug: string
}
export interface DocsNavGroup {
title?: string
items: DocsNavItem[]
}
export interface DocsSection {
key: "docs" | "cli" | "build" | "api"
title: string
landingSlug: string
groups: DocsNavGroup[]
}
export const docsSections: DocsSection[] = [
{
key: "docs",
title: "Docs",
landingSlug: "index",
groups: [
{
items: [
{ title: "Intro", slug: "index" },
{ title: "Config", slug: "config" },
],
},
{
title: "Configure",
items: [
{ title: "LSP", slug: "lsp" },
{ title: "Agents", slug: "agents" },
{ title: "Models", slug: "models" },
{ title: "Skills", slug: "skills" },
{ title: "Themes", slug: "themes" },
{ title: "Commands", slug: "commands" },
{ title: "Providers", slug: "providers" },
{ title: "Snapshots", slug: "snapshots" },
{ title: "Compaction", slug: "compaction" },
{ title: "Formatters", slug: "formatters" },
{ title: "References", slug: "references" },
{ title: "Attachments", slug: "attachments" },
{ title: "MCP servers", slug: "mcp-servers" },
{ title: "Permissions", slug: "permissions" },
{ title: "Instructions", slug: "instructions" },
{ title: "Session sharing", slug: "sharing" },
{ title: "Session warming", slug: "warming" },
],
},
{
items: [
{ title: "Migrate from V1", slug: "migrate-v1" },
{ title: "Troubleshooting", slug: "troubleshooting" },
],
},
],
},
{
key: "cli",
title: "CLI",
landingSlug: "cli/index",
groups: [
{
title: "Intro",
items: [
{ title: "Intro", slug: "cli/index" },
{ title: "Config", slug: "cli/config" },
],
},
{
title: "Configure",
items: [
{ title: "Theme", slug: "cli/theme" },
{ title: "Plugins", slug: "cli/plugins" },
{ title: "Keybinds", slug: "cli/keybinds" },
],
},
{
items: [{ title: "Providers", slug: "cli/providers" }],
},
],
},
{
key: "build",
title: "Build",
landingSlug: "build/index",
groups: [
{
title: "Build",
items: [
{ title: "SDK", slug: "build/sdk" },
{ title: "Build", slug: "build/index" },
{ title: "Client", slug: "build/client" },
{ title: "Plugins", slug: "build/plugins" },
],
},
],
},
{
key: "api",
title: "API",
landingSlug: "api",
groups: [
{
title: "API",
items: [{ title: "Overview", slug: "api" }],
},
],
},
]
export function docsHref(slug: string, anchor?: string) {
const path = slug === "index" ? "" : `${slug.replace(/\/index$/, "")}/`
return `${import.meta.env.BASE_URL}docs/${path}${anchor ? `#${anchor}` : ""}`
}
export function getDocsSection(slug: string) {
return (
docsSections.find((section) => section.groups.some((group) => group.items.some((item) => item.slug === slug))) ??
docsSections[0]
)
}
+18
View File
@@ -0,0 +1,18 @@
interface MarkdownNode {
type: string
url?: string
children?: MarkdownNode[]
}
export default function remarkDocsLinks(options: { base: string }) {
const docsBase = `${options.base.replace(/\/$/, "")}/docs`
return (tree: MarkdownNode) => {
const visit = (node: MarkdownNode) => {
if (node.type === "link" && node.url?.startsWith("/")) node.url = `${docsBase}${node.url}`
node.children?.forEach(visit)
}
visit(tree)
}
}
+629
View File
@@ -0,0 +1,629 @@
@font-face {
font-family: "OpenTUI Mono";
src: url("../assets/fonts/OpenTUIMono-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "OpenTUI Mono";
src: url("../assets/fonts/OpenTUIMono-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "OpenTUI Mono";
src: url("../assets/fonts/OpenTUIMono-Italic.woff2") format("woff2");
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: "OpenTUI Mono";
src: url("../assets/fonts/OpenTUIMono-BoldItalic.woff2") format("woff2");
font-weight: 700;
font-style: italic;
font-display: swap;
}
:root {
color-scheme: dark;
--background: #000;
--foreground: #ededed;
--muted: #929292;
--border: #303030;
--surface: #101010;
--surface-hover: #181818;
--link: #4d8eff;
--header-height: 3.875rem;
--frame-width: 76rem;
--content-width: 44rem;
--gutter: 1.5rem;
}
* {
box-sizing: border-box;
}
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
html {
background: var(--background);
scrollbar-gutter: stable;
}
body {
margin: 0;
background: var(--background);
color: var(--foreground);
font-family: "OpenTUI Mono", monospace;
font-size: 0.875rem;
line-height: 1.5;
}
button,
input {
color: inherit;
font: inherit;
}
a {
color: inherit;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.skip-link {
position: fixed;
z-index: 100;
top: -4rem;
left: 1rem;
padding: 0.5rem 0.75rem;
background: var(--foreground);
color: var(--background);
}
.skip-link:focus {
top: 1rem;
}
.site-header {
position: sticky;
z-index: 20;
top: 0;
height: var(--header-height);
border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
background: color-mix(in srgb, var(--background) 82%, transparent);
backdrop-filter: blur(16px);
}
.header-inner {
display: flex;
width: min(100%, var(--frame-width));
height: 100%;
margin-inline: auto;
padding-inline: var(--gutter);
align-items: center;
gap: 1.5rem;
}
.brand {
display: flex;
align-items: center;
}
.brand img {
display: block;
width: 7.75rem;
height: auto;
}
.site-header nav {
display: flex;
align-items: center;
gap: 1.25rem;
}
.site-header nav a {
padding-block: 0.25rem;
color: var(--muted);
font-weight: 700;
}
.site-header nav a:hover,
.site-header nav a[aria-current="page"] {
color: var(--foreground);
}
.site-header nav a[aria-current="page"] {
border-bottom: 1px solid;
text-decoration: none;
}
.header-spacer {
flex: 1;
}
.search-trigger {
display: inline-flex;
min-width: 9.5rem;
height: 2.25rem;
padding: 0 0.75rem;
cursor: pointer;
border: 1px solid var(--border);
background: transparent;
align-items: center;
justify-content: space-between;
color: var(--muted);
}
.search-trigger:hover {
border-color: var(--muted);
color: var(--foreground);
}
.search-trigger kbd {
font: inherit;
font-size: 0.75rem;
}
.github-link {
color: var(--muted);
font-weight: 700;
}
.docs-frame {
display: grid;
width: min(100%, var(--frame-width));
min-height: calc(100vh - var(--header-height));
margin-inline: auto;
grid-template-columns: 15rem minmax(0, var(--content-width)) minmax(12rem, 1fr);
}
.sidebar-column {
padding: 2.25rem 2rem 3rem var(--gutter);
border-right: 1px solid var(--border);
}
.docs-sidebar section + section {
margin-top: 2rem;
}
.docs-sidebar h2,
.docs-toc h2 {
margin: 0 0 0.625rem;
color: var(--foreground);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.docs-sidebar ul,
.docs-toc ul {
margin: 0;
padding: 0;
list-style: none;
}
.docs-sidebar li + li,
.docs-toc li + li {
margin-top: 0.375rem;
}
.docs-sidebar a,
.docs-toc a {
display: block;
padding-block: 0.125rem;
color: var(--muted);
}
.docs-sidebar a:hover,
.docs-sidebar a[aria-current="page"],
.docs-toc a:hover {
color: var(--foreground);
}
.docs-sidebar a[aria-current="page"] {
font-weight: 700;
}
main {
min-width: 0;
padding: 3rem clamp(2rem, 4vw, 4rem) 5rem;
}
.toc-column {
padding: 3rem var(--gutter) 3rem 2rem;
}
.docs-toc {
position: sticky;
top: calc(var(--header-height) + 2rem);
}
.docs-toc .nested {
padding-left: 1rem;
}
.mobile-navigation {
display: none;
}
.prose {
width: 100%;
max-width: var(--content-width);
overflow-wrap: break-word;
}
.prose > :first-child {
margin-top: 0;
}
.prose h1,
.prose h2,
.prose h3,
.prose h4 {
color: var(--foreground);
font-weight: 700;
}
.prose h1 {
margin: 0 0 3rem;
font-size: 1.875rem;
line-height: 1.2;
}
.prose h2 {
margin: 3rem 0 0.75rem;
font-size: 1.25rem;
line-height: 1.5;
}
.prose h3 {
margin: 2.25rem 0 0.75rem;
font-size: 1.125rem;
line-height: 1.5;
}
.prose h4 {
margin: 2rem 0 0.75rem;
font-size: 1rem;
}
.prose :is(h2, h3)[id] {
scroll-margin-top: calc(var(--header-height) + 1rem);
}
.heading-anchor {
margin-left: 0.75ch;
color: var(--muted);
font-weight: 400;
opacity: 0;
}
.prose :is(h2, h3):hover .heading-anchor,
.heading-anchor:focus-visible {
opacity: 1;
}
.prose p,
.prose ul,
.prose ol {
margin: 0 0 1.5rem;
}
.prose ul,
.prose ol {
padding-left: 1.5rem;
}
.prose li + li {
margin-top: 0.5rem;
}
.prose a {
color: var(--link);
}
.prose code {
font: inherit;
}
.prose :not(pre) > code {
padding: 0.0625rem 0.4ch;
background: color-mix(in srgb, currentColor 10%, transparent);
}
.prose pre {
max-width: 100%;
margin: 0 0 1.5rem;
padding: 0.875rem 1rem;
border: 1px solid var(--border);
background: var(--background) !important;
font: inherit;
font-size: 0.8125rem;
overflow-x: auto;
}
.prose pre span {
color: var(--shiki-dark) !important;
}
.astro-code-figure {
margin: 0 0 1.5rem;
border: 1px solid var(--border);
}
.astro-code-title {
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--border);
color: var(--muted);
font-size: 0.75rem;
}
.astro-code-figure pre {
margin: 0;
border: 0;
}
.prose blockquote {
margin: 0 0 1.5rem;
padding-left: 1rem;
border-left: 2px solid var(--muted);
color: var(--muted);
}
.prose hr {
margin: 3rem 0 1.5rem;
border: 0;
border-top: 1px solid var(--border);
}
.prose table {
width: 100%;
margin-bottom: 1.5rem;
border-collapse: collapse;
font-size: 0.8125rem;
}
.prose th,
.prose td {
padding: 0.5rem 1rem 0.5rem 0;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: top;
}
.prose img {
max-width: 100%;
height: auto;
}
.callout {
margin: 0 0 1.5rem;
padding: 1rem;
border: 1px solid var(--border);
}
.callout > strong {
display: block;
margin-bottom: 0.5rem;
text-transform: uppercase;
}
.callout > div > :last-child {
margin-bottom: 0;
}
.callout-tip {
border-color: #28503a;
background: #0b1b11;
}
.callout-warning {
border-color: #62471f;
background: #1c1408;
}
.docs-card-group {
display: grid;
margin-bottom: 1.5rem;
grid-template-columns: repeat(var(--card-columns), minmax(0, 1fr));
gap: 0.75rem;
}
.docs-card {
display: block;
padding: 1rem;
border: 1px solid var(--border);
color: var(--foreground) !important;
}
.docs-card:hover {
background: var(--surface);
text-decoration: none;
}
.docs-card strong {
display: block;
margin-bottom: 0.375rem;
}
.docs-card div {
color: var(--muted);
}
.docs-card div > :last-child {
margin-bottom: 0;
}
.docs-code-block {
position: relative;
}
.docs-code-block button {
position: absolute;
top: 0.5rem;
right: 0.5rem;
padding: 0.25rem 0.5rem;
cursor: pointer;
border: 0;
background: var(--surface);
color: var(--muted);
font-size: 0.75rem;
}
.search-dialog {
width: min(42rem, calc(100% - 2rem));
max-height: min(36rem, calc(100vh - 4rem));
margin: 10vh auto auto;
padding: 0;
border: 1px solid var(--border);
background: color-mix(in srgb, var(--background) 92%, transparent);
color: var(--foreground);
backdrop-filter: blur(16px);
}
.search-dialog::backdrop {
background: rgb(0 0 0 / 0.72);
}
.search-bar {
display: flex;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
align-items: center;
gap: 0.75rem;
}
.search-bar input {
min-width: 0;
flex: 1;
border: 0;
outline: 0;
background: transparent;
}
.search-bar button {
padding: 0;
border: 0;
background: transparent;
color: var(--muted);
}
.search-results {
max-height: 28rem;
padding: 0.5rem;
overflow-y: auto;
}
.search-results > p {
margin: 0;
padding: 0.75rem;
color: var(--muted);
}
.search-results a {
display: block;
padding: 0.75rem;
}
.search-results a:hover {
background: var(--surface-hover);
text-decoration: none;
}
.search-results strong,
.search-results span {
display: block;
}
.search-results span {
margin-top: 0.25rem;
color: var(--muted);
font-size: 0.8125rem;
}
@media (max-width: 64rem) {
.docs-frame {
grid-template-columns: 14rem minmax(0, 1fr);
}
.toc-column {
display: none;
}
}
@media (max-width: 48rem) {
:root {
--gutter: 1rem;
}
.site-header nav,
.github-link,
.search-trigger span,
.search-trigger kbd {
display: none;
}
.search-trigger {
min-width: 2.25rem;
}
.search-trigger::before {
content: ">";
}
.mobile-navigation {
display: block;
position: sticky;
z-index: 10;
top: var(--header-height);
border-bottom: 1px solid var(--border);
background: var(--background);
}
.mobile-navigation summary {
padding: 0.75rem var(--gutter);
cursor: pointer;
font-weight: 700;
}
.mobile-navigation .docs-sidebar {
max-height: calc(100vh - 8rem);
padding: 0.75rem var(--gutter) 1.5rem;
overflow-y: auto;
}
.docs-frame {
display: block;
}
.sidebar-column {
display: none;
}
main {
padding: 2rem var(--gutter) 4rem;
}
.prose h1 {
margin-bottom: 2rem;
}
.docs-card-group {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,36 @@
---
import { getCollection, render, type CollectionEntry } from "astro:content"
import Callout from "../../docs/components/Callout.astro"
import Card from "../../docs/components/Card.astro"
import CardGroup from "../../docs/components/CardGroup.astro"
import CodeBlock from "../../docs/components/CodeBlock.astro"
import DocsLayout from "../../docs/layouts/DocsLayout.astro"
interface Props {
entry: CollectionEntry<"docs">
}
export async function getStaticPaths() {
return (await getCollection("docs")).map((entry) => ({
params: {
slug: entry.id === "index" ? undefined : entry.id.replace(/\/index$/, ""),
},
props: { entry },
}))
}
export const prerender = true
const entry = Astro.props.entry
const rendered = await render(entry)
---
<DocsLayout
title={entry.data.title}
description={entry.data.description}
currentSlug={entry.id}
headings={rendered.headings}
showTableOfContents={entry.data.tableOfContents !== false}
>
<rendered.Content components={{ Callout, Card, CardGroup, CodeBlock }} />
</DocsLayout>
@@ -0,0 +1,19 @@
---
import DocsLayout from "../../../docs/layouts/DocsLayout.astro"
export const prerender = true
---
<DocsLayout
title="API"
description="OpenCode HTTP API reference and OpenAPI specification."
currentSlug="api"
headings={[]}
showTableOfContents={false}
>
<p>
OpenCode exposes an HTTP API from its server. Use <code>opencode2 api</code> for authenticated local requests, or
build against the generated OpenAPI specification.
</p>
<p><a href={`${import.meta.env.BASE_URL}openapi.json`}>View the OpenAPI specification</a>.</p>
</DocsLayout>
+17
View File
@@ -0,0 +1,17 @@
---
export const prerender = true
const destination = `${import.meta.env.BASE_URL}docs`
---
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta http-equiv="refresh" content={`0; url=${destination}`} />
<link rel="canonical" href={destination} />
<title>OpenCode Docs</title>
</head>
<body>
<a href={destination}>Continue to the OpenCode docs</a>
</body>
</html>
-114
View File
@@ -1,114 +0,0 @@
:root {
--blume-content-width: 650px;
}
:root[data-theme="dark"] {
--blume-foreground: #ffffff;
}
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
[data-blume-doc-grid] {
width: 100%;
max-width: 1152px;
}
[data-blume-header] {
width: 100%;
max-width: 1152px;
margin-inline: auto;
}
@media (min-width: 64rem) {
[data-blume-doc-grid] {
grid-template-columns: 15rem minmax(0, 1fr);
}
}
@media (min-width: 80rem) {
[data-blume-doc-grid]:has(> aside[aria-label="On this page"]) {
grid-template-columns: minmax(0, 1fr) minmax(0, 730px) minmax(0, 1fr);
}
}
[data-blume-toc] > p:first-child {
display: none;
}
[data-blume-page-actions] > details:has([data-open-in]) {
display: none;
}
:is([data-blume-nav-drawer], [data-blume-toc]) {
scrollbar-width: thin;
}
:is([data-blume-nav-drawer], [data-blume-toc])::-webkit-scrollbar {
width: 6px;
}
[data-install-code] pre code {
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.prose pre {
border: 0;
border-radius: 0;
padding: 0;
}
.prose pre[data-language] {
padding-top: 2.75rem;
}
.prose pre[data-language]::before {
border-bottom: 0;
padding-left: 0;
}
.prose pre > code {
padding: 0;
}
.prose {
color: var(--blume-foreground);
}
/* Match opentui.com/docs: bold headings and bold header nav links */
.prose :is(h1, h2, h3, h4) {
font-weight: 700;
}
[data-blume-header] nav[aria-label="Sections"] a {
font-weight: 700;
}
.prose a:not(.blume-heading-anchor, [data-blume-card]) {
color: var(--blume-accent);
}
.prose h1 {
font-size: 1.875rem;
line-height: 1.2;
margin-bottom: 2rem;
}
.prose h2 {
font-size: 1.25rem;
line-height: 1.3;
}
.prose h3 {
font-size: 1rem;
line-height: 1.4;
}
.prose h4 {
font-size: 1rem;
line-height: 1.4;
}
-1
View File
@@ -1,6 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".blume/.astro/types.d.ts", ".blume/src/env.d.ts", "**/*"],
"compilerOptions": {
"types": ["bun"]
}