Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 44 additions & 12 deletions packages/opencode/src/cli/cmd/tui/component/logo.tsx
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -71,15 +84,34 @@ export function Logo() {
}

return (
<box>
<For each={logo.left}>
{(line, index) => (
<box flexDirection="row" gap={1}>
<box flexDirection="row">{renderLine(line, theme.textMuted, false)}</box>
<box flexDirection="row">{renderLine(logo.right[index()], theme.text, true)}</box>
<Show when={!customLogo()?.disabled} fallback={null}>
<Show
when={customLogo()?.content}
fallback={
<box>
<For each={LogoConfig.glyphs.left}>
{(line, index) => (
<box flexDirection="row" gap={1}>
<box flexDirection="row">{renderLine(line, theme.textMuted, false)}</box>
<box flexDirection="row">{renderLine(LogoConfig.glyphs.right[index()], theme.text, true)}</box>
</box>
)}
</For>
</box>
)}
</For>
</box>
}
>
<box>
<For each={stripAnsi(customLogo()!.content!).split("\n")}>
{(line) => (
<box flexDirection="row">
<text fg={theme.text} selectable={false}>
{line}
</text>
</box>
)}
</For>
</box>
</Show>
</Show>
)
}
3 changes: 2 additions & 1 deletion packages/opencode/src/cli/cmd/uninstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/cli/cmd/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 2 additions & 1 deletion packages/opencode/src/cli/cmd/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
55 changes: 51 additions & 4 deletions packages/opencode/src/cli/logo.ts
Original file line number Diff line number Diff line change
@@ -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<Custom> {
// 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
31 changes: 28 additions & 3 deletions packages/opencode/src/cli/ui.ts
Original file line number Diff line number Diff line change
@@ -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())
Expand Down Expand Up @@ -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<string> {
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 = {
Expand Down Expand Up @@ -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)
})
Expand Down
6 changes: 6 additions & 0 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions packages/opencode/src/server/routes/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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({
Expand Down
20 changes: 20 additions & 0 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
CommandListResponses,
Config as Config3,
ConfigGetResponses,
ConfigLogoResponses,
ConfigProvidersResponses,
ConfigUpdateErrors,
ConfigUpdateResponses,
Expand Down Expand Up @@ -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<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
return (options?.client ?? this.client).get<ConfigLogoResponses, unknown, ThrowOnError>({
url: "/config/logo",
...options,
...params,
})
}

/**
* List config providers
*
Expand Down
31 changes: 31 additions & 0 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
/**
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading