diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index b486b68a93..1ae28ea874 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -97,6 +97,11 @@ jobs:
working-directory: packages/client
run: bun run check:generated
+ - name: Check generated documentation
+ if: runner.os == 'Linux'
+ working-directory: packages/docs
+ run: bun run check:generated
+
e2e:
name: e2e (${{ matrix.settings.name }})
if: github.ref_name != 'v2' && github.head_ref != 'v2'
diff --git a/packages/docs/README.md b/packages/docs/README.md
index 17b06848e1..1aff8cf6dc 100644
--- a/packages/docs/README.md
+++ b/packages/docs/README.md
@@ -19,4 +19,15 @@ bun validate
bun broken-links
```
+The V2 theme token reference is generated from
+`packages/tui/src/theme/v2/schema.ts`. Regenerate it after schema changes:
+
+```bash
+bun run generate
+```
+
+`bun validate` checks that the committed snippet is current. The repository's
+generation workflow also refreshes it on pushes to `dev`, so Mintlify always
+receives the generated MDX as part of the published docs tree.
+
The hosted preview is available at [opencode.mintlify.site](https://opencode.mintlify.site).
diff --git a/packages/docs/docs.json b/packages/docs/docs.json
index ff41ab4b92..a9349f2963 100644
--- a/packages/docs/docs.json
+++ b/packages/docs/docs.json
@@ -38,6 +38,7 @@
"attachments",
"compaction",
"warming",
+ "themes",
"formatters",
"lsp",
"references"
diff --git a/packages/docs/index.mdx b/packages/docs/index.mdx
index 9095853903..bbb3ccfdfe 100644
--- a/packages/docs/index.mdx
+++ b/packages/docs/index.mdx
@@ -158,6 +158,6 @@ limitations and safety details.
## Customize
-Make OpenCode your own by [picking a theme](https://opencode.ai/docs/themes), [customizing
+Make OpenCode your own by [picking a theme](/themes), [customizing
keybinds](https://opencode.ai/docs/keybinds), [configuring formatters](/formatters), [creating commands](/commands), or
editing the [OpenCode config](/config).
diff --git a/packages/docs/package.json b/packages/docs/package.json
index 01f35998c5..b8a9ead7ca 100644
--- a/packages/docs/package.json
+++ b/packages/docs/package.json
@@ -3,11 +3,15 @@
"name": "@opencode-ai/docs",
"private": true,
"scripts": {
- "dev": "bun --bun mint dev --no-open --port 3333",
- "validate": "bun --bun mint validate",
+ "dev": "bun run generate && bun --bun mint dev --no-open --port 3333",
+ "generate": "bun script/generate-theme-tokens.ts",
+ "check:generated": "bun script/generate-theme-tokens.ts --check",
+ "validate": "bun run check:generated && bun --bun mint validate",
"broken-links": "bun --bun mint broken-links"
},
"devDependencies": {
- "mint": "4.2.666"
+ "effect": "catalog:",
+ "mint": "4.2.666",
+ "prettier": "3.6.2"
}
}
diff --git a/packages/docs/script/generate-theme-tokens.ts b/packages/docs/script/generate-theme-tokens.ts
new file mode 100644
index 0000000000..0c441075f9
--- /dev/null
+++ b/packages/docs/script/generate-theme-tokens.ts
@@ -0,0 +1,136 @@
+#!/usr/bin/env bun
+
+import { Schema, SchemaAST } from "effect"
+import { format } from "prettier"
+import { ThemeDefinition, ThemeFile } from "../../tui/src/theme/v2/schema"
+
+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("
")} |`)
+ .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 ThemeFile
+Schema.decodeUnknownSync(ThemeFile)(example)
+const output = await format(
+ `{/* Generated by packages/docs/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
+\`packages/tui/src/theme/v2/schema.ts\`. 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..\`, 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/docs.")
+ 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)}`))
+}
diff --git a/packages/docs/snippets/generated/theme-tokens.mdx b/packages/docs/snippets/generated/theme-tokens.mdx
new file mode 100644
index 0000000000..932072f6cb
--- /dev/null
+++ b/packages/docs/snippets/generated/theme-tokens.mdx
@@ -0,0 +1,79 @@
+{/* Generated by packages/docs/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
+`packages/tui/src/theme/v2/schema.ts`. 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..`, 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`
`text.subdued` |
+| `text.action` | `text.action.primary.default`
`text.action.primary.$hovered`
`text.action.primary.$focused`
`text.action.primary.$pressed`
`text.action.primary.$selected`
`text.action.primary.$disabled`
`text.action.destructive.default`
`text.action.destructive.$hovered`
`text.action.destructive.$focused`
`text.action.destructive.$pressed`
`text.action.destructive.$selected`
`text.action.destructive.$disabled` |
+| `text.formfield` | `text.formfield.default`
`text.formfield.$hovered`
`text.formfield.$focused`
`text.formfield.$pressed`
`text.formfield.$selected`
`text.formfield.$disabled` |
+| `text.feedback` | `text.feedback.error.default`
`text.feedback.error.subdued`
`text.feedback.warning.default`
`text.feedback.warning.subdued`
`text.feedback.success.default`
`text.feedback.success.subdued`
`text.feedback.info.default`
`text.feedback.info.subdued` |
+| `background` | `background.default` |
+| `background.surface` | `background.surface.offset`
`background.surface.overlay` |
+| `background.action` | `background.action.primary.default`
`background.action.primary.$hovered`
`background.action.primary.$focused`
`background.action.primary.$pressed`
`background.action.primary.$selected`
`background.action.primary.$disabled`
`background.action.destructive.default`
`background.action.destructive.$hovered`
`background.action.destructive.$focused`
`background.action.destructive.$pressed`
`background.action.destructive.$selected`
`background.action.destructive.$disabled` |
+| `background.formfield` | `background.formfield.default`
`background.formfield.$hovered`
`background.formfield.$focused`
`background.formfield.$pressed`
`background.formfield.$selected`
`background.formfield.$disabled` |
+| `background.feedback` | `background.feedback.error.default`
`background.feedback.warning.default`
`background.feedback.success.default`
`background.feedback.info.default` |
+| `border` | `border.default` |
+| `scrollbar` | `scrollbar.default` |
+| `diff.text` | `diff.text.added`
`diff.text.removed`
`diff.text.context`
`diff.text.hunkHeader` |
+| `diff.background` | `diff.background.added`
`diff.background.removed`
`diff.background.context` |
+| `diff.highlight` | `diff.highlight.added`
`diff.highlight.removed` |
+| `diff.lineNumber` | `diff.lineNumber.text`
`diff.lineNumber.background.added`
`diff.lineNumber.background.removed` |
+| `syntax` | `syntax.comment`
`syntax.keyword`
`syntax.function`
`syntax.variable`
`syntax.string`
`syntax.number`
`syntax.type`
`syntax.operator`
`syntax.punctuation` |
+| `markdown` | `markdown.text`
`markdown.heading`
`markdown.link`
`markdown.linkText`
`markdown.code`
`markdown.blockQuote`
`markdown.emphasis`
`markdown.strong`
`markdown.horizontalRule`
`markdown.listItem`
`markdown.listEnumeration`
`markdown.image`
`markdown.imageText`
`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.
diff --git a/packages/docs/themes.mdx b/packages/docs/themes.mdx
new file mode 100644
index 0000000000..49f41768f6
--- /dev/null
+++ b/packages/docs/themes.mdx
@@ -0,0 +1,129 @@
+---
+title: "Themes"
+description: "Choose a built-in TUI theme or create a custom color scheme."
+---
+
+import ThemeTokens from "/snippets/generated/theme-tokens.mdx"
+
+OpenCode includes built-in light and dark themes and can load custom themes
+from your global configuration or a project directory. The default theme is
+`opencode`.
+
+## Choose a theme
+
+In the full-screen TUI, run:
+
+```text
+/themes
+```
+
+You can also open the picker with `ctrl+x`, then `t`, using the
+default keybindings.
+
+Use `/settings` to change both the theme and its color mode. OpenCode supports
+three modes:
+
+| Mode | Behavior |
+| -------- | -------------------------------------------------------- |
+| `system` | Follow the terminal's detected light or dark appearance. |
+| `dark` | Always use the theme's dark colors. |
+| `light` | Always use the theme's light colors. |
+
+Your selection is stored in `~/.config/opencode/cli.json`, or the equivalent
+path under `$XDG_CONFIG_HOME`:
+
+```json title="cli.json"
+{
+ "theme": {
+ "name": "tokyonight",
+ "mode": "system"
+ }
+}
+```
+
+
+ Theme selection applies to the full-screen TUI. Direct interactive runs use colors derived from the terminal palette
+ and honor only the color mode.
+
+
+## Built-in themes
+
+OpenCode currently includes:
+
+| | | |
+| ------------ | ------------------- | ---------------------- |
+| `aura` | `ayu` | `carbonfox` |
+| `catppuccin` | `catppuccin-frappe` | `catppuccin-macchiato` |
+| `cobalt2` | `cursor` | `dracula` |
+| `everforest` | `flexoki` | `github` |
+| `gruvbox` | `kanagawa` | `lucent-orng` |
+| `material` | `matrix` | `mercury` |
+| `monokai` | `nightowl` | `nord` |
+| `one-dark` | `opencode` | `orng` |
+| `osaka-jade` | `palenight` | `rosepine` |
+| `solarized` | `synthwave84` | `tokyonight` |
+| `vercel` | `vesper` | `zenburn` |
+
+When OpenCode can read your terminal palette, the picker also includes
+`system`. The `system` theme generates its colors from your terminal's
+foreground, background, and ANSI palette.
+
+## Custom themes
+
+Create a JSON file in either of these locations:
+
+```text
+~/.config/opencode/themes/my-theme.json
+.opencode/themes/my-theme.json
+```
+
+OpenCode checks the global theme directory first, followed by every
+`.opencode/themes` directory from the filesystem root down to the current
+directory. A more local file with the same filename overrides an earlier one.
+The filename becomes the theme name, so `my-theme.json` appears as `my-theme`.
+
+Custom theme files must be strict JSON. Comments and trailing commas are not
+supported.
+
+### Format
+
+V2 themes organize colors into hue scales and semantic tokens. Set `version`
+to `2` and define at least one of `light` or `dark`:
+
+
+ Native V2 custom theme files are not loaded directly by the current beta. Existing custom files use the V1 format and
+ are migrated to these tokens at runtime. This reference tracks the native V2 schema while direct file loading is
+ completed.
+
+
+By default, a theme inherits OpenCode's complete theme, so you only need to
+define overrides. Set `mergeMode` to `true` to inherit one mode from the other
+before applying that mode's overrides. Set `standalone` to `true` only when you
+intend to supply a complete independent theme.
+
+Each token accepts:
+
+- A hex color such as `"#5c9cf5"`
+- `"transparent"` to use the terminal default
+- A hue reference such as `"$hue.blue.500"`
+- Another semantic token reference such as `"$text.default"`
+
+Syntax and markdown tokens accept hex colors and hue references. Other
+semantic tokens can reference any semantic token.
+
+
+
+If you add or edit a custom theme while OpenCode is running, restart the TUI to
+reload it.
+
+## Terminal colors
+
+Themes display most accurately in a terminal with truecolor support. Check
+your terminal with:
+
+```bash
+echo $COLORTERM
+```
+
+Most modern terminals report `truecolor` or `24bit`. Without truecolor,
+OpenCode approximates theme colors using the available terminal palette.
diff --git a/packages/tui/src/theme/v2/schema.ts b/packages/tui/src/theme/v2/schema.ts
index 076bdfc635..76a33f7a09 100644
--- a/packages/tui/src/theme/v2/schema.ts
+++ b/packages/tui/src/theme/v2/schema.ts
@@ -243,7 +243,7 @@ const MergeModeDefinition = Schema.Struct({
"@context:overlay": Schema.optional(ThemeTokensDefinition),
})
export type MergeModeDefinition = Schema.Schema.Type
-export const ModeDefinition = Schema.Union([FileThemeDefinition, MergeModeDefinition])
+export const ModeDefinition = Schema.Union([MergeModeDefinition, FileThemeDefinition])
export type ModeDefinition = Schema.Schema.Type
const FileMetadata = {
diff --git a/packages/tui/test/theme/v2/resolve.test.ts b/packages/tui/test/theme/v2/resolve.test.ts
index 237b4c79fc..e91a79d2b2 100644
--- a/packages/tui/test/theme/v2/resolve.test.ts
+++ b/packages/tui/test/theme/v2/resolve.test.ts
@@ -192,8 +192,15 @@ test("standalone themes skip OpenCode defaults and use the red core fallback", (
})
test("uses defaults for the selected mode when it merges the other mode", () => {
- const theme = resolveThemeFile({ version: 2, light: { hue: light.hue }, dark: { mergeMode: true } }, "dark")
- expect(theme.background.default.toInts()).toEqual(resolveTheme(dark).background.default.toInts())
+ const theme = resolveThemeFile(
+ {
+ version: 2,
+ light: { hue: light.hue, background: { default: "#123456" } },
+ dark: { mergeMode: true },
+ },
+ "dark",
+ )
+ expect(theme.background.default.toInts()).toEqual([18, 52, 86, 255])
})
test("resolves matched action variants and states", () => {
diff --git a/script/generate.ts b/script/generate.ts
index 8fc251d89d..dbf38f8a3c 100755
--- a/script/generate.ts
+++ b/script/generate.ts
@@ -6,4 +6,6 @@ await $`bun ./packages/sdk/js/script/build.ts`
await $`bun dev generate > ../sdk/openapi.json`.cwd("packages/opencode")
+await $`bun run generate`.cwd("packages/docs")
+
await $`./script/format.ts`