From 381f6c47b46a2a4f89d37d8c698ada1b50c36057 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 22 Jul 2026 18:30:31 -0400 Subject: [PATCH] docs(tui): add generated V2 theme reference (#38396) --- .github/workflows/test.yml | 5 + packages/docs/README.md | 11 ++ packages/docs/docs.json | 1 + packages/docs/index.mdx | 2 +- packages/docs/package.json | 10 +- packages/docs/script/generate-theme-tokens.ts | 136 ++++++++++++++++++ .../docs/snippets/generated/theme-tokens.mdx | 79 ++++++++++ packages/docs/themes.mdx | 129 +++++++++++++++++ packages/tui/src/theme/v2/schema.ts | 2 +- packages/tui/test/theme/v2/resolve.test.ts | 11 +- script/generate.ts | 2 + 11 files changed, 381 insertions(+), 7 deletions(-) create mode 100644 packages/docs/script/generate-theme-tokens.ts create mode 100644 packages/docs/snippets/generated/theme-tokens.mdx create mode 100644 packages/docs/themes.mdx 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`