mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
Feat: Add startup logo palette picker (#1072)
* Add startup logo palette picker * Address logo picker review feedback
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
Reference in New Issue
Block a user