From 9d464f34885157c5c1361c351561eee747439aa8 Mon Sep 17 00:00:00 2001 From: gnanam1990 Date: Wed, 1 Apr 2026 23:57:45 +0530 Subject: [PATCH] feat: add gradient startup screen and remove old OPEN box logo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new startup screen with filled-block text logo and sunset gradient, printed to stdout before the Ink UI loads. Removes the old OPEN box logo from the chat UI since the new screen replaces it. Changes: - src/components/StartupScreen.ts (NEW) — gradient OPEN CLAUDE logo with provider info box (Provider, Model, Endpoint). Auto-detects active provider from env vars (OpenAI, Gemini, DeepSeek, Ollama, Groq, Mistral, Azure, LM Studio, Anthropic). Skipped in CI and non-TTY environments. - src/entrypoints/cli.tsx — calls printStartupScreen() at startup before Ink renders - src/components/Messages.tsx — removes from LogoHeader so the old OPEN box logo no longer appears in the chat UI Addresses #55. Co-Authored-By: Claude Sonnet 4.6 --- src/components/Messages.tsx | 2 +- src/components/StartupScreen.ts | 179 ++++++++++++++++++++++++++++++++ src/entrypoints/cli.tsx | 4 + 3 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 src/components/StartupScreen.ts diff --git a/src/components/Messages.tsx b/src/components/Messages.tsx index e244119c..90c320b4 100644 --- a/src/components/Messages.tsx +++ b/src/components/Messages.tsx @@ -59,7 +59,7 @@ const LogoHeader = React.memo(function LogoHeader(t0) { } = t0; let t1; if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; + t1 = null; $[0] = t1; } else { t1 = $[0]; diff --git a/src/components/StartupScreen.ts b/src/components/StartupScreen.ts new file mode 100644 index 00000000..602b3d08 --- /dev/null +++ b/src/components/StartupScreen.ts @@ -0,0 +1,179 @@ +/** + * OpenClaude startup screen — filled-block text logo with sunset gradient. + * Called once at CLI startup before the Ink UI renders. + * + * Addresses: https://github.com/Gitlawb/openclaude/issues/55 + */ + +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` + +function lerp(a: RGB, b: RGB, t: number): RGB { + return [ + Math.round(a[0] + (b[0] - a[0]) * t), + Math.round(a[1] + (b[1] - a[1]) * t), + Math.round(a[2] + (b[2] - a[2]) * t), + ] +} + +function gradAt(stops: RGB[], t: number): RGB { + const c = Math.max(0, Math.min(1, t)) + const s = c * (stops.length - 1) + const i = Math.floor(s) + if (i >= stops.length - 1) return stops[stops.length - 1] + return lerp(stops[i], stops[i + 1], s - i) +} + +function paintLine(text: string, stops: 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]}` + } + 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 = [ + ` \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2557`, + ` \u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u2550\u255d \u2588\u2588\u2588\u2557 \u2588\u2588\u2551`, + ` \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551`, + ` \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u2550\u255d \u2588\u2588\u2554\u2550\u2550\u2550\u255d \u2588\u2588\u2554\u2588\u2588\u2588\u2588\u2551`, + ` \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u255a\u2588\u2588\u2588\u2551`, + ` \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d \u255a\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d \u255a\u2550\u255d \u255a\u2550\u2550\u255d`, +] + +const LOGO_CLAUDE = [ + ` \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557`, + ` \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u2550\u255d \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u2550\u255d`, + ` \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 `, + ` \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2550\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2550\u255d `, + ` \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2551 \u255a\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255d \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557`, + ` \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d\u255a\u2550\u255d \u255a\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255d`, +] + +// ─── Provider detection ─────────────────────────────────────────────────────── + +function detectProvider(): { name: string; model: string; baseUrl: string; isLocal: boolean } { + const useGemini = process.env.CLAUDE_CODE_USE_GEMINI === '1' || process.env.CLAUDE_CODE_USE_GEMINI === 'true' + const useOpenAI = process.env.CLAUDE_CODE_USE_OPENAI === '1' || process.env.CLAUDE_CODE_USE_OPENAI === 'true' + + if (useGemini) { + const model = process.env.GEMINI_MODEL || 'gemini-2.0-flash' + const baseUrl = process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta/openai' + return { name: 'Google Gemini', model, baseUrl, isLocal: false } + } + + if (useOpenAI) { + const model = process.env.OPENAI_MODEL || 'gpt-4o' + const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1' + const isLocal = /localhost|127\.0\.0\.1|0\.0\.0\.0/.test(baseUrl) + let name = 'OpenAI' + if (/deepseek/i.test(baseUrl) || /deepseek/i.test(model)) name = 'DeepSeek' + else if (/openrouter/i.test(baseUrl)) name = 'OpenRouter' + else if (/together/i.test(baseUrl)) name = 'Together AI' + else if (/groq/i.test(baseUrl)) name = 'Groq' + else if (/mistral/i.test(baseUrl) || /mistral/i.test(model)) name = 'Mistral' + else if (/azure/i.test(baseUrl)) name = 'Azure OpenAI' + else if (/localhost:11434/i.test(baseUrl)) name = 'Ollama' + else if (/localhost:1234/i.test(baseUrl)) name = 'LM Studio' + else if (/llama/i.test(model)) name = 'Meta Llama' + else if (isLocal) name = 'Local' + return { name, model, baseUrl, isLocal } + } + + // Default: Anthropic + const model = process.env.ANTHROPIC_MODEL || process.env.CLAUDE_MODEL || 'claude-sonnet-4-6' + return { name: 'Anthropic', model, baseUrl: 'https://api.anthropic.com', isLocal: false } +} + +// ─── Box drawing ────────────────────────────────────────────────────────────── + +function boxRow(content: string, width: number, rawLen: number): string { + const pad = Math.max(0, width - 2 - rawLen) + return `${rgb(...BORDER)}\u2502${RESET}${content}${' '.repeat(pad)}${rgb(...BORDER)}\u2502${RESET}` +} + +// ─── Main ───────────────────────────────────────────────────────────────────── + +export function printStartupScreen(): void { + // Skip in non-interactive / CI / print mode + if (process.env.CI || !process.stdout.isTTY) return + + const p = detectProvider() + const W = 62 + const out: string[] = [] + + out.push('') + + // Gradient logo + const allLogo = [...LOGO_OPEN, '', ...LOGO_CLAUDE] + const total = allLogo.length + for (let i = 0; i < total; i++) { + const t = total > 1 ? i / (total - 1) : 0 + if (allLogo[i] === '') { + out.push('') + } else { + out.push(paintLine(allLogo[i], SUNSET_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('') + + // Provider info box + out.push(`${rgb(...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] + } + + const provC: RGB = p.isLocal ? [130, 175, 130] : ACCENT + let [r, l] = lbl('Provider', p.name, provC) + out.push(boxRow(r, W, l)) + ;[r, l] = lbl('Model', p.model) + out.push(boxRow(r, W, l)) + 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(`${rgb(...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 sLen = ` \u25cf ${sL} Ready \u2014 type /help to begin`.length + out.push(boxRow(sRow, W, sLen)) + + out.push(`${rgb(...BORDER)}\u255a${'\u2550'.repeat(W - 2)}\u255d${RESET}`) + out.push(` ${DIM}${rgb(...DIMCOL)}openclaude v${(globalThis as Record)['MACRO_DISPLAY_VERSION'] ?? '0.1.4'}${RESET}`) + out.push('') + + process.stdout.write(out.join('\n') + '\n') +} diff --git a/src/entrypoints/cli.tsx b/src/entrypoints/cli.tsx index eb14ef3e..71adb260 100644 --- a/src/entrypoints/cli.tsx +++ b/src/entrypoints/cli.tsx @@ -100,6 +100,10 @@ async function main(): Promise { validateProviderEnvOrExit() + // Print the gradient startup screen before the Ink UI loads + const { printStartupScreen } = await import('../components/StartupScreen.js') + printStartupScreen() + // For all other paths, load the startup profiler const { profileCheckpoint