* feat: add xAI as official provider - Add xAI preset to ProviderManager (alphabetical order) - Add xAI provider detection via XAI_API_KEY - Add xAI startup screen heuristic (x.ai base URL or grok model) - Add xAI status display properties - Add grok-4 and grok-3 context windows - Add xAI model fallbacks across all tiers - Fix JSDoc priority order in providerAutoDetect Co-Authored-By: Claude Opus 4.6 <noreply@openclaude.dev> * fix(xai): persist relaunch classification for xAI profiles Addresses reviewer feedback on feat/xai-official-provider: - isProcessEnvAlignedWithProfile now validates XAI_API_KEY for x.ai base URLs, mirroring the Bankr pattern. Without this, relaunch skips re-applying the profile, XAI_API_KEY stays unset, and getAPIProvider() falls back to 'openai'. - buildOpenAICompatibleStartupEnv now sets XAI_API_KEY when syncing active xAI profile to the legacy fallback file. - Adds 'xai' to VALID_PROVIDERS and --provider xai CLI flag support. - Adds xAI detection to providerDiscovery label heuristics. - Adds 'xai' to legacy ProviderProfile type/isProviderProfile guard. - Adds targeted tests for relaunch alignment, flag application, and discovery labeling. Co-Authored-By: OpenClaude <openclaude@gitlawb.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@openclaude.dev> Co-authored-by: OpenClaude <openclaude@gitlawb.com>
246 lines
13 KiB
TypeScript
246 lines
13 KiB
TypeScript
/**
|
|
* 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
|
|
*/
|
|
|
|
import { isLocalProviderUrl, resolveProviderRequest } from '../services/api/providerConfig.js'
|
|
import { getLocalOpenAICompatibleProviderLabel } from '../utils/providerDiscovery.js'
|
|
import { getSettings_DEPRECATED } from '../utils/settings/settings.js'
|
|
import { parseUserSpecifiedModel } from '../utils/model/model.js'
|
|
import { containsExactZaiGlmModelId, isZaiBaseUrl } from '../utils/zaiProvider.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`
|
|
|
|
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 ───────────────────────────────────────────────────────
|
|
|
|
export function detectProvider(modelOverride?: string): { 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 useGithub = process.env.CLAUDE_CODE_USE_GITHUB === '1' || process.env.CLAUDE_CODE_USE_GITHUB === 'true'
|
|
const useOpenAI = process.env.CLAUDE_CODE_USE_OPENAI === '1' || process.env.CLAUDE_CODE_USE_OPENAI === 'true'
|
|
const useMistral = process.env.CLAUDE_CODE_USE_MISTRAL === '1' || process.env.CLAUDE_CODE_USE_MISTRAL === 'true'
|
|
|
|
if (useGemini) {
|
|
const model = modelOverride || 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 (useMistral) {
|
|
const model = modelOverride || process.env.MISTRAL_MODEL || 'devstral-latest'
|
|
const baseUrl = process.env.MISTRAL_BASE_URL || 'https://api.mistral.ai/v1'
|
|
return { name: 'Mistral', model, baseUrl, isLocal: false }
|
|
}
|
|
|
|
if (useGithub) {
|
|
const model = modelOverride || process.env.OPENAI_MODEL || 'github:copilot'
|
|
const baseUrl =
|
|
process.env.OPENAI_BASE_URL || 'https://api.githubcopilot.com'
|
|
return { name: 'GitHub Copilot', model, baseUrl, isLocal: false }
|
|
}
|
|
|
|
if (useOpenAI) {
|
|
const rawModel = modelOverride || process.env.OPENAI_MODEL || 'gpt-4o'
|
|
const resolvedRequest = resolveProviderRequest({
|
|
model: rawModel,
|
|
baseUrl: process.env.OPENAI_BASE_URL,
|
|
})
|
|
const baseUrl = resolvedRequest.baseUrl
|
|
const isLocal = isLocalProviderUrl(baseUrl)
|
|
let name = 'OpenAI'
|
|
// Explicit dedicated-provider env flags win.
|
|
if (process.env.NVIDIA_NIM) name = 'NVIDIA NIM'
|
|
else if (process.env.MINIMAX_API_KEY) name = 'MiniMax'
|
|
else if (
|
|
resolvedRequest.transport === 'codex_responses' ||
|
|
baseUrl.includes('chatgpt.com/backend-api/codex')
|
|
)
|
|
name = 'Codex'
|
|
// Base URL is authoritative — must precede rawModel checks so aggregators
|
|
// (OpenRouter/Together/Groq) aren't mislabelled as DeepSeek/Kimi/etc.
|
|
// when routed to models whose IDs contain a vendor prefix. See issue #855.
|
|
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 (/azure/i.test(baseUrl)) name = 'Azure OpenAI'
|
|
else if (/nvidia/i.test(baseUrl)) name = 'NVIDIA NIM'
|
|
else if (/minimax/i.test(baseUrl)) name = 'MiniMax'
|
|
else if (/api\.kimi\.com/i.test(baseUrl)) name = 'Moonshot AI - Kimi Code'
|
|
else if (/moonshot/i.test(baseUrl)) name = 'Moonshot AI - API'
|
|
else if (/deepseek/i.test(baseUrl)) name = 'DeepSeek'
|
|
else if (/x\.ai/i.test(baseUrl)) name = 'xAI'
|
|
else if (isZaiBaseUrl(baseUrl)) name = 'Z.AI - GLM'
|
|
else if (/mistral/i.test(baseUrl)) name = 'Mistral'
|
|
// rawModel fallback — fires only when base URL is generic/custom.
|
|
else if (/nvidia/i.test(rawModel)) name = 'NVIDIA NIM'
|
|
else if (/minimax/i.test(rawModel)) name = 'MiniMax'
|
|
else if (/\bkimi-for-coding\b/i.test(rawModel))
|
|
name = 'Moonshot AI - Kimi Code'
|
|
else if (/\bkimi-k/i.test(rawModel) || /moonshot/i.test(rawModel))
|
|
name = 'Moonshot AI - API'
|
|
else if (/deepseek/i.test(rawModel)) name = 'DeepSeek'
|
|
else if (/grok/i.test(rawModel)) name = 'xAI'
|
|
else if (containsExactZaiGlmModelId(rawModel)) name = 'Z.AI - GLM'
|
|
else if (/mistral/i.test(rawModel)) name = 'Mistral'
|
|
else if (/llama/i.test(rawModel)) name = 'Meta Llama'
|
|
else if (/bankr/i.test(baseUrl)) name = 'Bankr'
|
|
else if (/bankr/i.test(rawModel)) name = 'Bankr'
|
|
else if (isLocal) name = getLocalOpenAICompatibleProviderLabel(baseUrl)
|
|
|
|
// Resolve model alias to actual model name + reasoning effort
|
|
let displayModel = resolvedRequest.resolvedModel
|
|
if (resolvedRequest.reasoning?.effort) {
|
|
displayModel = `${displayModel} (${resolvedRequest.reasoning.effort})`
|
|
}
|
|
|
|
return { name, model: displayModel, baseUrl, isLocal }
|
|
}
|
|
|
|
// Default: Anthropic - check settings.model first, then env vars
|
|
const settings = getSettings_DEPRECATED() || {}
|
|
const modelSetting = modelOverride || settings.model || process.env.ANTHROPIC_MODEL || process.env.CLAUDE_MODEL || 'claude-sonnet-4-6'
|
|
const resolvedModel = parseUserSpecifiedModel(modelSetting)
|
|
const baseUrl = process.env.ANTHROPIC_BASE_URL ?? 'https://api.anthropic.com'
|
|
const isLocal = isLocalProviderUrl(baseUrl)
|
|
return { name: 'Anthropic', model: resolvedModel, baseUrl, isLocal }
|
|
}
|
|
|
|
// ─── 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(modelOverride?: string): void {
|
|
// Skip in non-interactive / CI / print mode
|
|
if (process.env.CI || !process.stdout.isTTY) return
|
|
|
|
const p = detectProvider(modelOverride)
|
|
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 ${RESET}${rgb(...ACCENT)}v${MACRO.DISPLAY_VERSION ?? MACRO.VERSION}${RESET}`)
|
|
out.push('')
|
|
|
|
process.stdout.write(out.join('\n') + '\n')
|
|
}
|