Feat: Add startup logo palette picker (#1072)

* Add startup logo palette picker

* Address logo picker review feedback
This commit is contained in:
TechBrewBoss
2026-05-09 00:01:53 +08:00
committed by GitHub
parent 41b2496101
commit ed7b6972f9
9 changed files with 311 additions and 39 deletions
+3
View File
@@ -62,6 +62,7 @@ import bughunter from './commands/bughunter/index.js'
import terminalSetup from './commands/terminalSetup/index.js'
import usage from './commands/usage/index.js'
import theme from './commands/theme/index.js'
import logo from './commands/logo/index.js'
import vim from './commands/vim/index.js'
import { feature } from 'bun:bundle'
import { isBuddyEnabled } from './buddy/feature.js'
@@ -324,6 +325,7 @@ const COMMANDS = memoize((): Command[] => [
stickers,
tag,
theme,
logo,
feedback,
review,
ultrareview,
@@ -643,6 +645,7 @@ export const REMOTE_SAFE_COMMANDS: Set<Command> = new Set([
clear, // Clear screen
help, // Show help
theme, // Change terminal theme
logo, // Change startup logo color scheme
color, // Change agent color
vim, // Toggle vim mode
cost, // Show session cost (local cost tracking)
+21
View File
@@ -0,0 +1,21 @@
import type { Command } from '../../commands.js'
import {
DEFAULT_LOGO_PALETTE,
LOGO_PALETTE_LABELS,
isLogoPaletteName,
} from '../../components/StartupScreen.palettes.js'
import { getGlobalConfig } from '../../utils/config.js'
const logo = {
type: 'local-jsx',
name: 'logo',
get description(): string {
const current = getGlobalConfig().logoColor
const shown = isLogoPaletteName(current) ? current : DEFAULT_LOGO_PALETTE
return `Change the startup logo color scheme (current: ${LOGO_PALETTE_LABELS[shown]})`
},
isHidden: false,
load: () => import('./logo.js'),
} satisfies Command
export default logo
+50
View File
@@ -0,0 +1,50 @@
import * as React from 'react'
import { LogoPicker } from '../../components/LogoPicker.js'
import {
DEFAULT_LOGO_PALETTE,
LOGO_PALETTE_LABELS,
isLogoPaletteName,
type LogoPaletteName,
} from '../../components/StartupScreen.palettes.js'
import type {
LocalJSXCommandCall,
LocalJSXCommandOnDone,
} from '../../types/command.js'
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
type Props = {
onDone: LocalJSXCommandOnDone
}
function LogoPickerCommand({ onDone }: Props): React.ReactElement {
const initial = React.useMemo<LogoPaletteName>(() => {
const current = getGlobalConfig().logoColor
return isLogoPaletteName(current) ? current : DEFAULT_LOGO_PALETTE
}, [])
const handleSelect = React.useCallback(
(chosen: LogoPaletteName) => {
saveGlobalConfig(c => ({ ...c, logoColor: chosen }))
onDone(
`Startup logo set to ${LOGO_PALETTE_LABELS[chosen]}. Visible on next launch.`,
)
},
[onDone],
)
const handleCancel = React.useCallback(() => {
onDone('Logo picker dismissed', { display: 'system' })
}, [onDone])
return (
<LogoPicker
initial={initial}
onSelect={handleSelect}
onCancel={handleCancel}
/>
)
}
export const call: LocalJSXCommandCall = async (onDone, _context) => {
return <LogoPickerCommand onDone={onDone} />
}
+57
View File
@@ -0,0 +1,57 @@
import * as React from 'react'
import { Box, Text } from '../ink.js'
import { Select } from './CustomSelect/index.js'
import {
LOGO_PALETTE_LABELS,
LOGO_PALETTE_NAMES,
LOGO_PALETTES,
type LogoPaletteName,
} from './StartupScreen.palettes.js'
import { ANSI_RESET, ansiRgb } from '../utils/terminalAnsi.js'
export type LogoPickerProps = {
initial?: LogoPaletteName
onSelect: (name: LogoPaletteName) => void
onCancel: () => void
}
/**
* Render a colored preview swatch using the palette's gradient stops.
* Six block characters, one per gradient stop — gives an immediate sense
* of the palette's range without re-painting the full ASCII logo.
*/
function previewSwatch(name: LogoPaletteName): string {
const stops = LOGO_PALETTES[name].gradient
return stops
.map(([r, g, b]) => `${ansiRgb(r, g, b)}\u2587${ANSI_RESET}`)
.join('')
}
export function LogoPicker({
initial,
onSelect,
onCancel,
}: LogoPickerProps): React.ReactElement {
const options = React.useMemo(
() =>
LOGO_PALETTE_NAMES.map(name => ({
label: `${previewSwatch(name)} ${LOGO_PALETTE_LABELS[name]}`,
value: name,
})),
[],
)
return (
<Box flexDirection="column" gap={1}>
<Text bold>Choose the startup logo color scheme</Text>
<Select
options={options}
onChange={value => onSelect(value as LogoPaletteName)}
onCancel={onCancel}
visibleOptionCount={options.length}
defaultValue={initial}
defaultFocusValue={initial}
/>
</Box>
)
}
@@ -0,0 +1,29 @@
import { describe, expect, test } from 'bun:test'
import {
DEFAULT_LOGO_PALETTE,
LOGO_PALETTE_NAMES,
LOGO_PALETTES,
isLogoPaletteName,
resolveLogoPalette,
} from './StartupScreen.palettes.js'
describe('startup logo palettes', () => {
test('valid palette names resolve to their palette', () => {
for (const name of LOGO_PALETTE_NAMES) {
expect(isLogoPaletteName(name)).toBe(true)
expect(resolveLogoPalette(name)).toBe(LOGO_PALETTES[name])
}
})
test('missing and invalid palette names fall back to the default', () => {
expect(resolveLogoPalette(undefined)).toBe(LOGO_PALETTES[DEFAULT_LOGO_PALETTE])
expect(resolveLogoPalette('not-a-palette')).toBe(
LOGO_PALETTES[DEFAULT_LOGO_PALETTE],
)
expect(isLogoPaletteName('not-a-palette')).toBe(false)
})
test('palette names stay in sync with defined palettes', () => {
expect(LOGO_PALETTE_NAMES).toEqual(Object.keys(LOGO_PALETTES))
})
})
+104
View File
@@ -0,0 +1,104 @@
/**
* Color palettes for the startup splash logo.
* Selected via /logo, persisted in GlobalConfig.logoColor.
*/
export type RGB = readonly [number, number, number]
export type LogoPalette = {
/** Gradient stops painted top→bottom across the ASCII logo rows. */
gradient: readonly RGB[]
/** Highlight color for tagline, version label, and the ✦ marker. */
accent: RGB
/** Soft body text color (tagline value, label values). */
cream: RGB
/** Dim color for label names and the openclaude prefix. */
dim: RGB
/** Box-drawing border color. */
border: RGB
}
export const LOGO_PALETTES = {
sunset: {
gradient: [
[255, 180, 100],
[240, 140, 80],
[217, 119, 87],
[193, 95, 60],
[160, 75, 55],
[130, 60, 50],
],
accent: [240, 148, 100],
cream: [220, 195, 170],
dim: [120, 100, 82],
border: [100, 80, 65],
},
forest: {
gradient: [
[180, 240, 170],
[130, 215, 130],
[85, 180, 95],
[55, 145, 75],
[40, 110, 60],
[25, 80, 45],
],
accent: [120, 200, 120],
cream: [200, 220, 190],
dim: [90, 120, 90],
border: [70, 95, 70],
},
ocean: {
gradient: [
[170, 220, 255],
[125, 185, 240],
[80, 150, 220],
[55, 115, 190],
[40, 85, 150],
[25, 55, 110],
],
accent: [110, 180, 230],
cream: [195, 215, 235],
dim: [90, 115, 145],
border: [70, 90, 115],
},
monochrome: {
gradient: [
[225, 225, 225],
[195, 195, 195],
[160, 160, 160],
[125, 125, 125],
[95, 95, 95],
[70, 70, 70],
],
accent: [200, 200, 200],
cream: [210, 210, 210],
dim: [120, 120, 120],
border: [95, 95, 95],
},
} as const satisfies Record<string, LogoPalette>
export type LogoPaletteName = keyof typeof LOGO_PALETTES
export const LOGO_PALETTE_NAMES = Object.keys(LOGO_PALETTES) as LogoPaletteName[]
export const DEFAULT_LOGO_PALETTE: LogoPaletteName = 'sunset'
export const LOGO_PALETTE_LABELS: Record<LogoPaletteName, string> = {
sunset: 'Sunset (default)',
forest: 'Forest green',
ocean: 'Ocean blue',
monochrome: 'Monochrome',
}
export function isLogoPaletteName(value: unknown): value is LogoPaletteName {
return (
typeof value === 'string' &&
Object.prototype.hasOwnProperty.call(LOGO_PALETTES, value)
)
}
export function resolveLogoPalette(name: string | undefined): LogoPalette {
return isLogoPaletteName(name)
? LOGO_PALETTES[name]
: LOGO_PALETTES[DEFAULT_LOGO_PALETTE]
}
+32 -39
View File
@@ -14,15 +14,17 @@ import { getLocalOpenAICompatibleProviderLabel } from '../utils/providerDiscover
import { getSettings_DEPRECATED } from '../utils/settings/settings.js'
import { parseUserSpecifiedModel } from '../utils/model/model.js'
import { DEFAULT_GEMINI_MODEL } from '../utils/providerProfile.js'
import { getGlobalConfig } from '../utils/config.js'
import { ANSI_DIM, ANSI_RESET, ansiRgb } from '../utils/terminalAnsi.js'
import {
resolveLogoPalette,
type RGB,
} from './StartupScreen.palettes.js'
declare const MACRO: { VERSION: string; DISPLAY_VERSION?: string }
const ESC = '\x1b['
const RESET = `${ESC}0m`
const DIM = `${ESC}2m`
type RGB = [number, number, number]
const rgb = (r: number, g: number, b: number) => `${ESC}38;2;${r};${g};${b}m`
const RESET = ANSI_RESET
const DIM = ANSI_DIM
function lerp(a: RGB, b: RGB, t: number): RGB {
return [
@@ -32,7 +34,7 @@ function lerp(a: RGB, b: RGB, t: number): RGB {
]
}
function gradAt(stops: RGB[], t: number): RGB {
function gradAt(stops: readonly RGB[], t: number): RGB {
const c = Math.max(0, Math.min(1, t))
const s = c * (stops.length - 1)
const i = Math.floor(s)
@@ -40,32 +42,16 @@ function gradAt(stops: RGB[], t: number): RGB {
return lerp(stops[i], stops[i + 1], s - i)
}
function paintLine(text: string, stops: RGB[], lineT: number): string {
export function paintLine(text: string, stops: readonly RGB[], lineT: number): string {
let out = ''
for (let i = 0; i < text.length; i++) {
const t = text.length > 1 ? lineT * 0.5 + (i / (text.length - 1)) * 0.5 : lineT
const [r, g, b] = gradAt(stops, t)
out += `${rgb(r, g, b)}${text[i]}`
out += `${ansiRgb(r, g, b)}${text[i]}`
}
return out + RESET
}
// ─── Colors ───────────────────────────────────────────────────────────────────
const SUNSET_GRAD: RGB[] = [
[255, 180, 100],
[240, 140, 80],
[217, 119, 87],
[193, 95, 60],
[160, 75, 55],
[130, 60, 50],
]
const ACCENT: RGB = [240, 148, 100]
const CREAM: RGB = [220, 195, 170]
const DIMCOL: RGB = [120, 100, 82]
const BORDER: RGB = [100, 80, 65]
// ─── Filled Block Text Logo ───────────────────────────────────────────────────
const LOGO_OPEN = [
@@ -180,9 +166,9 @@ export function detectProvider(modelOverride?: string): { name: string; model: s
// ─── Box drawing ──────────────────────────────────────────────────────────────
function boxRow(content: string, width: number, rawLen: number): string {
function boxRow(content: string, width: number, rawLen: number, border: RGB): string {
const pad = Math.max(0, width - 2 - rawLen)
return `${rgb(...BORDER)}\u2502${RESET}${content}${' '.repeat(pad)}${rgb(...BORDER)}\u2502${RESET}`
return `${ansiRgb(...border)}\u2502${RESET}${content}${' '.repeat(pad)}${ansiRgb(...border)}\u2502${RESET}`
}
// ─── Main ─────────────────────────────────────────────────────────────────────
@@ -191,6 +177,13 @@ export function printStartupScreen(modelOverride?: string): void {
// Skip in non-interactive / CI / print mode
if (process.env.CI || !process.stdout.isTTY) return
const palette = resolveLogoPalette(getGlobalConfig().logoColor)
const ACCENT = palette.accent
const CREAM = palette.cream
const DIMCOL = palette.dim
const BORDER = palette.border
const GRAD = palette.gradient
const p = detectProvider(modelOverride)
const W = 62
const out: string[] = []
@@ -205,43 +198,43 @@ export function printStartupScreen(modelOverride?: string): void {
if (allLogo[i] === '') {
out.push('')
} else {
out.push(paintLine(allLogo[i], SUNSET_GRAD, t))
out.push(paintLine(allLogo[i], GRAD, t))
}
}
out.push('')
// Tagline
out.push(` ${rgb(...ACCENT)}\u2726${RESET} ${rgb(...CREAM)}Any model. Every tool. Zero limits.${RESET} ${rgb(...ACCENT)}\u2726${RESET}`)
out.push(` ${ansiRgb(...ACCENT)}\u2726${RESET} ${ansiRgb(...CREAM)}Any model. Every tool. Zero limits.${RESET} ${ansiRgb(...ACCENT)}\u2726${RESET}`)
out.push('')
// Provider info box
out.push(`${rgb(...BORDER)}\u2554${'\u2550'.repeat(W - 2)}\u2557${RESET}`)
out.push(`${ansiRgb(...BORDER)}\u2554${'\u2550'.repeat(W - 2)}\u2557${RESET}`)
const lbl = (k: string, v: string, c: RGB = CREAM): [string, number] => {
const padK = k.padEnd(9)
return [` ${DIM}${rgb(...DIMCOL)}${padK}${RESET} ${rgb(...c)}${v}${RESET}`, ` ${padK} ${v}`.length]
return [` ${DIM}${ansiRgb(...DIMCOL)}${padK}${RESET} ${ansiRgb(...c)}${v}${RESET}`, ` ${padK} ${v}`.length]
}
const provC: RGB = p.isLocal ? [130, 175, 130] : ACCENT
let [r, l] = lbl('Provider', p.name, provC)
out.push(boxRow(r, W, l))
out.push(boxRow(r, W, l, BORDER))
;[r, l] = lbl('Model', p.model)
out.push(boxRow(r, W, l))
out.push(boxRow(r, W, l, BORDER))
const ep = p.baseUrl.length > 38 ? p.baseUrl.slice(0, 35) + '...' : p.baseUrl
;[r, l] = lbl('Endpoint', ep)
out.push(boxRow(r, W, l))
out.push(boxRow(r, W, l, BORDER))
out.push(`${rgb(...BORDER)}\u2560${'\u2550'.repeat(W - 2)}\u2563${RESET}`)
out.push(`${ansiRgb(...BORDER)}\u2560${'\u2550'.repeat(W - 2)}\u2563${RESET}`)
const sC: RGB = p.isLocal ? [130, 175, 130] : ACCENT
const sL = p.isLocal ? 'local' : 'cloud'
const sRow = ` ${rgb(...sC)}\u25cf${RESET} ${DIM}${rgb(...DIMCOL)}${sL}${RESET} ${DIM}${rgb(...DIMCOL)}Ready \u2014 type ${RESET}${rgb(...ACCENT)}/help${RESET}${DIM}${rgb(...DIMCOL)} to begin${RESET}`
const sRow = ` ${ansiRgb(...sC)}\u25cf${RESET} ${DIM}${ansiRgb(...DIMCOL)}${sL}${RESET} ${DIM}${ansiRgb(...DIMCOL)}Ready \u2014 type ${RESET}${ansiRgb(...ACCENT)}/help${RESET}${DIM}${ansiRgb(...DIMCOL)} to begin${RESET}`
const sLen = ` \u25cf ${sL} Ready \u2014 type /help to begin`.length
out.push(boxRow(sRow, W, sLen))
out.push(boxRow(sRow, W, sLen, BORDER))
out.push(`${rgb(...BORDER)}\u255a${'\u2550'.repeat(W - 2)}\u255d${RESET}`)
out.push(` ${DIM}${rgb(...DIMCOL)}openclaude ${RESET}${rgb(...ACCENT)}v${MACRO.DISPLAY_VERSION ?? MACRO.VERSION}${RESET}`)
out.push(`${ansiRgb(...BORDER)}\u255a${'\u2550'.repeat(W - 2)}\u255d${RESET}`)
out.push(` ${DIM}${ansiRgb(...DIMCOL)}openclaude ${RESET}${ansiRgb(...ACCENT)}v${MACRO.DISPLAY_VERSION ?? MACRO.VERSION}${RESET}`)
out.push('')
process.stdout.write(out.join('\n') + '\n')
+7
View File
@@ -624,6 +624,12 @@ export type GlobalConfig = {
// Knowledge Graph configuration
knowledgeGraphEnabled: boolean
// Startup splash logo color scheme — set via /logo. See
// src/components/StartupScreen.palettes.ts for valid values. Stored as a
// plain string (validated on read) to avoid pulling a UI module into the
// config layer. Falls back to 'sunset' if missing or unrecognized.
logoColor?: string
}
/**
@@ -722,6 +728,7 @@ export const GLOBAL_CONFIG_KEYS = [
'remoteControlAtStartup',
'remoteDialogSeen',
'knowledgeGraphEnabled',
'logoColor',
] as const
export type GlobalConfigKey = (typeof GLOBAL_CONFIG_KEYS)[number]
+8
View File
@@ -0,0 +1,8 @@
const ESC = '\x1b['
export const ANSI_RESET = `${ESC}0m`
export const ANSI_DIM = `${ESC}2m`
export function ansiRgb(r: number, g: number, b: number): string {
return `${ESC}38;2;${r};${g};${b}m`
}