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