From 8161f4dd28f88b3487b0f07029bd5712d1915405 Mon Sep 17 00:00:00 2001 From: Josh Brooks Date: Tue, 3 Feb 2026 20:35:16 +0000 Subject: [PATCH] feat(opencode): add custom logo configuration Add the ability to customize or disable the CLI/TUI logo via config. - Add 'logo' field to config schema (false to disable, string path to custom file) - Add Logo.load() function to load custom logo from configured path - Add UI.logoAsync() for async logo loading with config support - Add /config/logo API endpoint to serve custom logo content - Update TUI logo component to support custom logos (strips ANSI codes) - Update web, upgrade, uninstall commands to use async logo loading - Add documentation for logo configuration --- .../src/cli/cmd/tui/component/logo.tsx | 56 ++++++++++++---- packages/opencode/src/cli/cmd/uninstall.ts | 3 +- packages/opencode/src/cli/cmd/upgrade.ts | 3 +- packages/opencode/src/cli/cmd/web.ts | 3 +- packages/opencode/src/cli/logo.ts | 55 ++++++++++++++-- packages/opencode/src/cli/ui.ts | 31 ++++++++- packages/opencode/src/config/config.ts | 6 ++ packages/opencode/src/server/routes/config.ts | 31 +++++++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 20 ++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 31 +++++++++ packages/sdk/openapi.json | 64 +++++++++++++++++++ packages/web/src/content/docs/config.mdx | 35 ++++++++++ 12 files changed, 316 insertions(+), 22 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx index 8e6208b140b2..0b6fb720f173 100644 --- a/packages/opencode/src/cli/cmd/tui/component/logo.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx @@ -1,16 +1,29 @@ import { TextAttributes, RGBA } from "@opentui/core" -import { For, type JSX } from "solid-js" +import { For, Show, createResource, type JSX } from "solid-js" import { useTheme, tint } from "@tui/context/theme" -import { logo, marks } from "@/cli/logo" +import { Logo as LogoConfig } from "@/cli/logo" +import { useSDK } from "@tui/context/sdk" // Shadow markers (rendered chars in parens): // _ = full shadow cell (space with bg=shadow) // ^ = letter top, shadow bottom (▀ with fg=letter, bg=shadow) // ~ = shadow top only (▀ with fg=shadow) -const SHADOW_MARKER = new RegExp(`[${marks}]`) +const SHADOW_MARKER = new RegExp(`[${LogoConfig.marks}]`) + +// Strip ANSI escape codes from text (TUI uses its own color system) +const ANSI_REGEX = /\x1b\[[0-9;]*m/g +function stripAnsi(text: string): string { + return text.replace(ANSI_REGEX, "") +} export function Logo() { const { theme } = useTheme() + const sdk = useSDK() + + const [customLogo] = createResource(async () => { + const result = await sdk.client.config.logo({}).catch(() => null) + return result?.data + }) const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => { const shadow = tint(theme.background, fg, 0.25) @@ -71,15 +84,34 @@ export function Logo() { } return ( - - - {(line, index) => ( - - {renderLine(line, theme.textMuted, false)} - {renderLine(logo.right[index()], theme.text, true)} + + + + {(line, index) => ( + + {renderLine(line, theme.textMuted, false)} + {renderLine(LogoConfig.glyphs.right[index()], theme.text, true)} + + )} + - )} - - + } + > + + + {(line) => ( + + + {line} + + + )} + + + + ) } diff --git a/packages/opencode/src/cli/cmd/uninstall.ts b/packages/opencode/src/cli/cmd/uninstall.ts index de41f32a0d14..d4139321205e 100644 --- a/packages/opencode/src/cli/cmd/uninstall.ts +++ b/packages/opencode/src/cli/cmd/uninstall.ts @@ -53,7 +53,8 @@ export const UninstallCommand = { handler: async (args: UninstallArgs) => { UI.empty() - UI.println(UI.logo(" ")) + const logoText = await UI.logoAsync(" ") + if (logoText) UI.println(logoText) UI.empty() prompts.intro("Uninstall OpenCode") diff --git a/packages/opencode/src/cli/cmd/upgrade.ts b/packages/opencode/src/cli/cmd/upgrade.ts index 0182056633cb..d01074468714 100644 --- a/packages/opencode/src/cli/cmd/upgrade.ts +++ b/packages/opencode/src/cli/cmd/upgrade.ts @@ -21,7 +21,8 @@ export const UpgradeCommand = { }, handler: async (args: { target?: string; method?: string }) => { UI.empty() - UI.println(UI.logo(" ")) + const logoText = await UI.logoAsync(" ") + if (logoText) UI.println(logoText) UI.empty() prompts.intro("Upgrade") const detectedMethod = await Installation.method() diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 0fe056f21f2f..1662bad26971 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -39,7 +39,8 @@ export const WebCommand = cmd({ const opts = await resolveNetworkOptions(args) const server = Server.listen(opts) UI.empty() - UI.println(UI.logo(" ")) + const logoText = await UI.logoAsync(" ") + if (logoText) UI.println(logoText) UI.empty() if (opts.hostname === "0.0.0.0") { diff --git a/packages/opencode/src/cli/logo.ts b/packages/opencode/src/cli/logo.ts index 44fb93c15b34..38464e9c0f7e 100644 --- a/packages/opencode/src/cli/logo.ts +++ b/packages/opencode/src/cli/logo.ts @@ -1,6 +1,53 @@ -export const logo = { - left: [" ", "█▀▀█ █▀▀█ █▀▀█ █▀▀▄", "█__█ █__█ █^^^ █__█", "▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀~~▀"], - right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"], +import path from "path" +import { Config } from "../config/config" + +export namespace Logo { + // Default OpenCode logo glyphs + // Special characters: _ = bg fill, ^ = fg on bg half block, ~ = shadow half block + export const glyphs = { + left: [" ", "█▀▀█ █▀▀█ █▀▀█ █▀▀▄", "█__█ █__█ █^^^ █__█", "▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀~~▀"], + right: [" ▄ ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"], + } + + export const marks = "_^~" + + export type Custom = string | false | undefined + + /** + * Load a custom logo from the configured path. + * Returns the logo content as a string, or undefined to use default, or false to disable. + * Gracefully falls back to global config if instance context is not available. + */ + type LogoConfig = { logo?: string | false } + + function parseInlineConfig(): LogoConfig { + const inline = process.env.OPENCODE_CONFIG_CONTENT + if (!inline) return {} + return JSON.parse(inline) as LogoConfig + } + + export async function load(): Promise { + // Try full config first, fall back to global config, then inline config env var + const config: LogoConfig = await Config.get() + .catch(() => Config.getGlobal()) + .catch(() => parseInlineConfig()) + .catch(() => ({})) + if (config.logo === false) return false + if (!config.logo) return undefined + + const logoPath = config.logo.startsWith("~") + ? path.join(process.env.HOME || "", config.logo.slice(1)) + : path.isAbsolute(config.logo) + ? config.logo + : path.resolve(config.logo) + + return Bun.file(logoPath) + .text() + .then((content) => content.trimEnd()) + .catch(() => undefined) + } } -export const marks = "_^~" +// Legacy export for backwards compatibility +export const logo = Logo.glyphs +export const marks = Logo.marks diff --git a/packages/opencode/src/cli/ui.ts b/packages/opencode/src/cli/ui.ts index 39396997c65d..7d9b77ea684b 100644 --- a/packages/opencode/src/cli/ui.ts +++ b/packages/opencode/src/cli/ui.ts @@ -1,7 +1,7 @@ import z from "zod" import { EOL } from "os" import { NamedError } from "@opencode-ai/util/error" -import { logo as glyphs } from "./logo" +import { Logo } from "./logo" export namespace UI { export const CancelledError = NamedError.create("UICancelledError", z.void()) @@ -40,7 +40,32 @@ export namespace UI { blank = true } + /** + * Render the default OpenCode logo (sync version for CLI help). + */ export function logo(pad?: string) { + return renderDefaultLogo(pad) + } + + /** + * Load and render logo based on config (async version). + * Uses custom logo if configured, otherwise falls back to default. + * Returns empty string if logo is disabled. + */ + export async function logoAsync(pad?: string): Promise { + const custom = await Logo.load() + if (custom === false) return "" + if (custom) return renderCustomLogo(custom, pad) + return renderDefaultLogo(pad) + } + + function renderCustomLogo(content: string, pad?: string): string { + const lines = content.split("\n") + if (!pad) return lines.join(EOL) + return lines.map((line) => pad + line).join(EOL) + } + + function renderDefaultLogo(pad?: string): string { const result: string[] = [] const reset = "\x1b[0m" const left = { @@ -77,11 +102,11 @@ export namespace UI { } return parts.join("") } - glyphs.left.forEach((row, index) => { + Logo.glyphs.left.forEach((row, index) => { if (pad) result.push(pad) result.push(draw(row, left.fg, left.shadow, left.bg)) result.push(gap) - const other = glyphs.right[index] ?? "" + const other = Logo.glyphs.right[index] ?? "" result.push(draw(other, right.fg, right.shadow, right.bg)) result.push(EOL) }) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index c464fcb64ab8..b1cb8a04b417 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1039,6 +1039,12 @@ export namespace Config { export const Info = z .object({ $schema: z.string().optional().describe("JSON schema reference for configuration validation"), + logo: z + .union([z.literal(false), z.string()]) + .optional() + .describe( + "Custom logo configuration. Set to false to disable, or provide a path to a text file containing the logo (supports ANSI escape codes).", + ), logLevel: Log.Level.optional().describe("Log level"), server: Server.optional().describe("Server configuration for opencode serve and web commands"), command: z diff --git a/packages/opencode/src/server/routes/config.ts b/packages/opencode/src/server/routes/config.ts index 85d28f6aa6b8..bb1e15d399d9 100644 --- a/packages/opencode/src/server/routes/config.ts +++ b/packages/opencode/src/server/routes/config.ts @@ -3,6 +3,7 @@ import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" import { Config } from "../../config/config" import { Provider } from "../../provider/provider" +import { Logo } from "../../cli/logo" import { mapValues } from "remeda" import { errors } from "../error" import { Log } from "../../util/log" @@ -58,6 +59,36 @@ export const ConfigRoutes = lazy(() => return c.json(config) }, ) + .get( + "/logo", + describeRoute({ + summary: "Get custom logo", + description: "Get the custom logo content if configured, or null to use the default logo.", + operationId: "config.logo", + responses: { + 200: { + description: "Logo content", + content: { + "application/json": { + schema: resolver( + z.object({ + content: z.string().nullable().describe("Custom logo content, or null to use default"), + disabled: z.boolean().describe("Whether the logo is disabled"), + }), + ), + }, + }, + }, + }, + }), + async (c) => { + const custom = await Logo.load() + return c.json({ + content: custom === false ? null : (custom ?? null), + disabled: custom === false, + }) + }, + ) .get( "/providers", describeRoute({ diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 7a4f4e40cf30..e01d5eb0e5c5 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -16,6 +16,7 @@ import type { CommandListResponses, Config as Config3, ConfigGetResponses, + ConfigLogoResponses, ConfigProvidersResponses, ConfigUpdateErrors, ConfigUpdateResponses, @@ -822,6 +823,25 @@ export class Config2 extends HeyApiClient { }) } + /** + * Get custom logo + * + * Get the custom logo content if configured, or null to use the default logo. + */ + public logo( + parameters?: { + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }]) + return (options?.client ?? this.client).get({ + url: "/config/logo", + ...options, + ...params, + }) + } + /** * List config providers * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 86a0c7e42564..3796e183e8b8 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1312,6 +1312,10 @@ export type Config = { * JSON schema reference for configuration validation */ $schema?: string + /** + * Custom logo configuration. Set to false to disable, or provide a path to a text file containing the logo (supports ANSI escape codes). + */ + logo?: false | string logLevel?: LogLevel server?: ServerConfig /** @@ -2451,6 +2455,33 @@ export type ConfigUpdateResponses = { export type ConfigUpdateResponse = ConfigUpdateResponses[keyof ConfigUpdateResponses] +export type ConfigLogoData = { + body?: never + path?: never + query?: { + directory?: string + } + url: "/config/logo" +} + +export type ConfigLogoResponses = { + /** + * Logo content + */ + 200: { + /** + * Custom logo content, or null to use default + */ + content: string | null + /** + * Whether the logo is disabled + */ + disabled: boolean + } +} + +export type ConfigLogoResponse = ConfigLogoResponses[keyof ConfigLogoResponses] + export type ConfigProvidersData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index a66ef63647f2..19fc05fb7b5d 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -1052,6 +1052,58 @@ ] } }, + "/config/logo": { + "get": { + "operationId": "config.logo", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + } + ], + "summary": "Get custom logo", + "description": "Get the custom logo content if configured, or null to use the default logo.", + "responses": { + "200": { + "description": "Logo content", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "content": { + "description": "Custom logo content, or null to use default", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "disabled": { + "description": "Whether the logo is disabled", + "type": "boolean" + } + }, + "required": ["content", "disabled"] + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.config.logo({\n ...\n})" + } + ] + } + }, "/config/providers": { "get": { "operationId": "config.providers", @@ -10419,6 +10471,18 @@ "description": "JSON schema reference for configuration validation", "type": "string" }, + "logo": { + "description": "Custom logo configuration. Set to false to disable, or provide a path to a text file containing the logo (supports ANSI escape codes).", + "anyOf": [ + { + "type": "boolean", + "const": false + }, + { + "type": "string" + } + ] + }, "logLevel": { "$ref": "#/components/schemas/LogLevel" }, diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index a9c39bd59f3f..46f6ae7597de 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -319,6 +319,41 @@ Set your UI theme in `tui.json`. --- +### Logo + +You can customize or disable the CLI logo through the `logo` option. This is useful for enterprise deployments or custom branding. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "logo": "~/.config/opencode/my-logo.txt" +} +``` + +The logo file should contain plain text ASCII art: + +```text title="my-logo.txt" +╔═══════════════════════════╗ +║ ACME Corporation ║ +║ Powered by OpenCode ║ +╚═══════════════════════════╝ +``` + +To disable the logo entirely: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "logo": false +} +``` + +:::note +The custom logo appears in the TUI home screen and CLI commands like `opencode web`, `opencode upgrade`, and `opencode uninstall`. The CLI help (`--help`) always shows the default OpenCode logo. ANSI escape codes for colors are supported in CLI output but are stripped in the TUI. +::: + +--- + ### Agents You can configure specialized agents for specific tasks through the `agent` option.