From 063546c71f184d32b16992d59be08265e8be93bc Mon Sep 17 00:00:00 2001 From: rainhole Date: Tue, 7 Apr 2026 14:49:23 +0800 Subject: [PATCH 1/3] Fix setup dialog keyboard input in Terminal.app --- src/interactiveHelpers.tsx | 334 +++++++++++++++---------------------- 1 file changed, 139 insertions(+), 195 deletions(-) diff --git a/src/interactiveHelpers.tsx b/src/interactiveHelpers.tsx index 3c9946b29..80179e653 100644 --- a/src/interactiveHelpers.tsx +++ b/src/interactiveHelpers.tsx @@ -1,11 +1,8 @@ -import { feature } from 'bun:bundle' -import { appendFileSync } from 'fs' -import React from 'react' -import { logEvent } from 'src/services/analytics/index.js' -import { - gracefulShutdown, - gracefulShutdownSync, -} from 'src/utils/gracefulShutdown.js' +import { feature } from 'bun:bundle'; +import { appendFileSync } from 'fs'; +import React from 'react'; +import { logEvent } from 'src/services/analytics/index.js'; +import { gracefulShutdown, gracefulShutdownSync } from 'src/utils/gracefulShutdown.js'; import { type ChannelEntry, getAllowedChannels, @@ -13,64 +10,59 @@ import { setHasDevChannels, setSessionTrustAccepted, setStatsStore, -} from './bootstrap/state.js' -import type { Command } from './commands.js' -import { createStatsStore, type StatsStore } from './context/stats.js' -import { getSystemContext } from './context.js' -import { initializeTelemetryAfterTrust } from './entrypoints/init.js' -import { isSynchronizedOutputSupported } from '@anthropic/ink' -import type { RenderOptions, Root, TextProps } from '@anthropic/ink' -import { KeybindingSetup } from './keybindings/KeybindingProviderSetup.js' -import { startDeferredPrefetches } from './main.js' +} from './bootstrap/state.js'; +import type { Command } from './commands.js'; +import { createStatsStore, type StatsStore } from './context/stats.js'; +import { getSystemContext } from './context.js'; +import { initializeTelemetryAfterTrust } from './entrypoints/init.js'; +import { isSynchronizedOutputSupported } from '@anthropic/ink'; +import type { RenderOptions, Root, TextProps } from '@anthropic/ink'; +import { KeybindingSetup } from './keybindings/KeybindingProviderSetup.js'; +import { startDeferredPrefetches } from './main.js'; import { checkGate_CACHED_OR_BLOCKING, initializeGrowthBook, resetGrowthBook, -} from './services/analytics/growthbook.js' -import { isQualifiedForGrove } from './services/api/grove.js' -import { handleMcpjsonServerApprovals } from './services/mcpServerApproval.js' -import { AppStateProvider } from './state/AppState.js' -import { onChangeAppState } from './state/onChangeAppState.js' -import { normalizeApiKeyForConfig } from './utils/authPortable.js' +} from './services/analytics/growthbook.js'; +import { isQualifiedForGrove } from './services/api/grove.js'; +import { handleMcpjsonServerApprovals } from './services/mcpServerApproval.js'; +import { AppStateProvider } from './state/AppState.js'; +import { onChangeAppState } from './state/onChangeAppState.js'; +import { normalizeApiKeyForConfig } from './utils/authPortable.js'; import { getExternalClaudeMdIncludes, getMemoryFiles, shouldShowClaudeMdExternalIncludesWarning, -} from './utils/claudemd.js' +} from './utils/claudemd.js'; import { checkHasTrustDialogAccepted, getCustomApiKeyStatus, getGlobalConfig, saveGlobalConfig, -} from './utils/config.js' -import { updateDeepLinkTerminalPreference } from './utils/deepLink/terminalPreference.js' -import { isEnvTruthy, isRunningOnHomespace } from './utils/envUtils.js' -import { type FpsMetrics, FpsTracker } from './utils/fpsTracker.js' -import { updateGithubRepoPathMapping } from './utils/githubRepoPathMapping.js' -import { applyConfigEnvironmentVariables } from './utils/managedEnv.js' -import type { PermissionMode } from './utils/permissions/PermissionMode.js' -import { getBaseRenderOptions } from './utils/renderOptions.js' -import { getSettingsWithAllErrors } from './utils/settings/allErrors.js' -import { - hasAutoModeOptIn, - hasSkipDangerousModePermissionPrompt, -} from './utils/settings/settings.js' +} from './utils/config.js'; +import { updateDeepLinkTerminalPreference } from './utils/deepLink/terminalPreference.js'; +import { isEnvTruthy, isRunningOnHomespace } from './utils/envUtils.js'; +import { type FpsMetrics, FpsTracker } from './utils/fpsTracker.js'; +import { updateGithubRepoPathMapping } from './utils/githubRepoPathMapping.js'; +import { applyConfigEnvironmentVariables } from './utils/managedEnv.js'; +import type { PermissionMode } from './utils/permissions/PermissionMode.js'; +import { getBaseRenderOptions } from './utils/renderOptions.js'; +import { getSettingsWithAllErrors } from './utils/settings/allErrors.js'; +import { stopCapturingEarlyInput } from './utils/earlyInput.js'; +import { hasAutoModeOptIn, hasSkipDangerousModePermissionPrompt } from './utils/settings/settings.js'; export function completeOnboarding(): void { saveGlobalConfig(current => ({ ...current, hasCompletedOnboarding: true, lastOnboardingVersion: MACRO.VERSION, - })) + })); } -export function showDialog( - root: Root, - renderer: (done: (result: T) => void) => React.ReactNode, -): Promise { +export function showDialog(root: Root, renderer: (done: (result: T) => void) => React.ReactNode): Promise { return new Promise(resolve => { - const done = (result: T): void => void resolve(result) - root.render(renderer(done)) - }) + const done = (result: T): void => void resolve(result); + root.render(renderer(done)); + }); } /** @@ -79,12 +71,8 @@ export function showDialog( * console.error is swallowed by Ink's patchConsole, so we render * through the React tree instead. */ -export async function exitWithError( - root: Root, - message: string, - beforeExit?: () => Promise, -): Promise { - return exitWithMessage(root, message, { color: 'error', beforeExit }) +export async function exitWithError(root: Root, message: string, beforeExit?: () => Promise): Promise { + return exitWithMessage(root, message, { color: 'error', beforeExit }); } /** @@ -97,21 +85,19 @@ export async function exitWithMessage( root: Root, message: string, options?: { - color?: TextProps['color'] - exitCode?: number - beforeExit?: () => Promise + color?: TextProps['color']; + exitCode?: number; + beforeExit?: () => Promise; }, ): Promise { - const { Text } = await import('@anthropic/ink') - const color = options?.color - const exitCode = options?.exitCode ?? 1 - root.render( - color ? {message} : {message}, - ) - root.unmount() - await options?.beforeExit?.() + const { Text } = await import('@anthropic/ink'); + const color = options?.color; + const exitCode = options?.exitCode ?? 1; + root.render(color ? {message} : {message}); + root.unmount(); + await options?.beforeExit?.(); // eslint-disable-next-line custom-rules/no-process-exit -- exit after Ink unmount - process.exit(exitCode) + process.exit(exitCode); } /** @@ -123,25 +109,29 @@ export function showSetupDialog( renderer: (done: (result: T) => void) => React.ReactNode, options?: { onChangeAppState?: typeof onChangeAppState }, ): Promise { + // Early input capture starts before main.tsx loads so fast typers don't lose + // their first prompt. Once we show an interactive setup dialog, though, that + // capture layer becomes the wrong owner for stdin and can swallow normal + // keys before Ink's handlers see them. Stop it right before rendering any + // setup UI so onboarding/trust/login dialogs receive direct keyboard input. + stopCapturingEarlyInput(); + return showDialog(root, done => ( {renderer(done)} - )) + )); } /** * Render the main UI into the root and wait for it to exit. * Handles the common epilogue: start deferred prefetches, wait for exit, graceful shutdown. */ -export async function renderAndRun( - root: Root, - element: React.ReactNode, -): Promise { - root.render(element) - startDeferredPrefetches() - await root.waitUntilExit() - await gracefulShutdown(0) +export async function renderAndRun(root: Root, element: React.ReactNode): Promise { + root.render(element); + startDeferredPrefetches(); + await root.waitUntilExit(); + await gracefulShutdown(0); } export async function showSetupScreens( @@ -153,33 +143,33 @@ export async function showSetupScreens( devChannels?: ChannelEntry[], ): Promise { if ( - "production" === 'test' || + 'production' === 'test' || isEnvTruthy(false) || process.env.IS_DEMO // Skip onboarding in demo mode ) { - return false + return false; } - const config = getGlobalConfig() - let onboardingShown = false + const config = getGlobalConfig(); + let onboardingShown = false; if ( !config.theme || !config.hasCompletedOnboarding // always show onboarding at least once ) { - onboardingShown = true - const { Onboarding } = await import('./components/Onboarding.js') + onboardingShown = true; + const { Onboarding } = await import('./components/Onboarding.js'); await showSetupDialog( root, done => ( { - completeOnboarding() - void done() + completeOnboarding(); + void done(); }} /> ), { onChangeAppState }, - ) + ); } // Always show the trust dialog in interactive sessions, regardless of permission mode. @@ -193,83 +183,71 @@ export async function showSetupScreens( // If it returns true, the TrustDialog would auto-resolve regardless of // security features, so we can skip the dynamic import and render cycle. if (!checkHasTrustDialogAccepted()) { - const { TrustDialog } = await import( - './components/TrustDialog/TrustDialog.js' - ) - await showSetupDialog(root, done => ( - - )) + const { TrustDialog } = await import('./components/TrustDialog/TrustDialog.js'); + await showSetupDialog(root, done => ); } // Signal that trust has been verified for this session. // GrowthBook checks this to decide whether to include auth headers. - setSessionTrustAccepted(true) + setSessionTrustAccepted(true); // Reset and reinitialize GrowthBook after trust is established. // Defense for login/logout: clears any prior client so the next init // picks up fresh auth headers. - resetGrowthBook() - void initializeGrowthBook() + resetGrowthBook(); + void initializeGrowthBook(); // Now that trust is established, prefetch system context if it wasn't already - void getSystemContext() + void getSystemContext(); // If settings are valid, check for any mcp.json servers that need approval - const { errors: allErrors } = getSettingsWithAllErrors() + const { errors: allErrors } = getSettingsWithAllErrors(); if (allErrors.length === 0) { - await handleMcpjsonServerApprovals(root) + await handleMcpjsonServerApprovals(root); } // Check for claude.md includes that need approval if (await shouldShowClaudeMdExternalIncludesWarning()) { - const externalIncludes = getExternalClaudeMdIncludes( - await getMemoryFiles(true), - ) - const { ClaudeMdExternalIncludesDialog } = await import( - './components/ClaudeMdExternalIncludesDialog.js' - ) + const externalIncludes = getExternalClaudeMdIncludes(await getMemoryFiles(true)); + const { ClaudeMdExternalIncludesDialog } = await import('./components/ClaudeMdExternalIncludesDialog.js'); await showSetupDialog(root, done => ( - - )) + + )); } } // Track current repo path for teleport directory switching (fire-and-forget) // This must happen AFTER trust to prevent untrusted directories from poisoning the mapping - void updateGithubRepoPathMapping() + void updateGithubRepoPathMapping(); if (feature('LODESTONE')) { - updateDeepLinkTerminalPreference() + updateDeepLinkTerminalPreference(); } // Apply full environment variables after trust dialog is accepted OR in bypass mode // In bypass mode (CI/CD, automation), we trust the environment so apply all variables // In normal mode, this happens after the trust dialog is accepted // This includes potentially dangerous environment variables from untrusted sources - applyConfigEnvironmentVariables() + applyConfigEnvironmentVariables(); // Initialize telemetry after env vars are applied so OTEL endpoint env vars and // otelHeadersHelper (which requires trust to execute) are available. // Defer to next tick so the OTel dynamic import resolves after first render // instead of during the pre-render microtask queue. - setImmediate(() => initializeTelemetryAfterTrust()) + setImmediate(() => initializeTelemetryAfterTrust()); if (await isQualifiedForGrove()) { - const { GroveDialog } = await import('src/components/grove/Grove.js') + const { GroveDialog } = await import('src/components/grove/Grove.js'); const decision = await showSetupDialog(root, done => ( - )) + )); if (decision === 'escape') { - logEvent('tengu_grove_policy_exited', {}) - gracefulShutdownSync(0) - return false + logEvent('tengu_grove_policy_exited', {}); + gracefulShutdownSync(0); + return false; } } @@ -277,36 +255,24 @@ export async function showSetupScreens( // On homespace, ANTHROPIC_API_KEY is preserved in process.env for child // processes but ignored by Claude Code itself (see auth.ts). if (process.env.ANTHROPIC_API_KEY && !isRunningOnHomespace()) { - const customApiKeyTruncated = normalizeApiKeyForConfig( - process.env.ANTHROPIC_API_KEY, - ) - const keyStatus = getCustomApiKeyStatus(customApiKeyTruncated) + const customApiKeyTruncated = normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY); + const keyStatus = getCustomApiKeyStatus(customApiKeyTruncated); if (keyStatus === 'new') { - const { ApproveApiKey } = await import('./components/ApproveApiKey.js') + const { ApproveApiKey } = await import('./components/ApproveApiKey.js'); await showSetupDialog( root, - done => ( - - ), + done => , { onChangeAppState }, - ) + ); } } if ( - (permissionMode === 'bypassPermissions' || - allowDangerouslySkipPermissions) && + (permissionMode === 'bypassPermissions' || allowDangerouslySkipPermissions) && !hasSkipDangerousModePermissionPrompt() ) { - const { BypassPermissionsModeDialog } = await import( - './components/BypassPermissionsModeDialog.js' - ) - await showSetupDialog(root, done => ( - - )) + const { BypassPermissionsModeDialog } = await import('./components/BypassPermissionsModeDialog.js'); + await showSetupDialog(root, done => ); } if (feature('TRANSCRIPT_CLASSIFIER')) { @@ -315,16 +281,10 @@ export async function showSetupScreens( // consent for an unavailable feature is pointless. The // verifyAutoModeGateAccess notification will explain why instead. if (permissionMode === 'auto' && !hasAutoModeOptIn()) { - const { AutoModeOptInDialog } = await import( - './components/AutoModeOptInDialog.js' - ) + const { AutoModeOptInDialog } = await import('./components/AutoModeOptInDialog.js'); await showSetupDialog(root, done => ( - gracefulShutdownSync(1)} - declineExits - /> - )) + gracefulShutdownSync(1)} declineExits /> + )); } } @@ -342,15 +302,14 @@ export async function showSetupScreens( // initializeGrowthBook promise fired earlier). Also warms the // isChannelsEnabled() check in the dev-channels dialog below. if (getAllowedChannels().length > 0 || (devChannels?.length ?? 0) > 0) { - await checkGate_CACHED_OR_BLOCKING('tengu_harbor') + await checkGate_CACHED_OR_BLOCKING('tengu_harbor'); } if (devChannels && devChannels.length > 0) { - const [{ isChannelsEnabled }, { getClaudeAIOAuthTokens }] = - await Promise.all([ - import('./services/mcp/channelAllowlist.js'), - import('./utils/auth.js'), - ]) + const [{ isChannelsEnabled }, { getClaudeAIOAuthTokens }] = await Promise.all([ + import('./services/mcp/channelAllowlist.js'), + import('./utils/auth.js'), + ]); // Skip the dialog when channels are blocked (tengu_harbor off or no // OAuth) — accepting then immediately seeing "not available" in // ChannelsNotice is worse than no dialog. Append entries anyway so @@ -359,80 +318,65 @@ export async function showSetupScreens( // (hasNonDev check); the allowlist bypass it also grants is moot // since the gate blocks upstream. if (!isChannelsEnabled() || !getClaudeAIOAuthTokens()?.accessToken) { - setAllowedChannels([ - ...getAllowedChannels(), - ...devChannels.map(c => ({ ...c, dev: true })), - ]) - setHasDevChannels(true) + setAllowedChannels([...getAllowedChannels(), ...devChannels.map(c => ({ ...c, dev: true }))]); + setHasDevChannels(true); } else { - const { DevChannelsDialog } = await import( - './components/DevChannelsDialog.js' - ) + const { DevChannelsDialog } = await import('./components/DevChannelsDialog.js'); await showSetupDialog(root, done => ( { // Mark dev entries per-entry so the allowlist bypass doesn't leak // to --channels entries when both flags are passed. - setAllowedChannels([ - ...getAllowedChannels(), - ...devChannels.map(c => ({ ...c, dev: true })), - ]) - setHasDevChannels(true) - void done() + setAllowedChannels([...getAllowedChannels(), ...devChannels.map(c => ({ ...c, dev: true }))]); + setHasDevChannels(true); + void done(); }} /> - )) + )); } } } // Show Chrome onboarding for first-time Claude in Chrome users - if ( - claudeInChrome && - !getGlobalConfig().hasCompletedClaudeInChromeOnboarding - ) { - const { ClaudeInChromeOnboarding } = await import( - './components/ClaudeInChromeOnboarding.js' - ) - await showSetupDialog(root, done => ( - - )) + if (claudeInChrome && !getGlobalConfig().hasCompletedClaudeInChromeOnboarding) { + const { ClaudeInChromeOnboarding } = await import('./components/ClaudeInChromeOnboarding.js'); + await showSetupDialog(root, done => ); } - return onboardingShown + return onboardingShown; } export function getRenderContext(exitOnCtrlC: boolean): { - renderOptions: RenderOptions - getFpsMetrics: () => FpsMetrics | undefined - stats: StatsStore + renderOptions: RenderOptions; + getFpsMetrics: () => FpsMetrics | undefined; + stats: StatsStore; } { - let lastFlickerTime = 0 - const baseOptions = getBaseRenderOptions(exitOnCtrlC) + let lastFlickerTime = 0; + const baseOptions = getBaseRenderOptions(exitOnCtrlC); // Log analytics event when stdin override is active if (baseOptions.stdin) { - logEvent('tengu_stdin_interactive', {}) + logEvent('tengu_stdin_interactive', {}); } - const fpsTracker = new FpsTracker() - const stats = createStatsStore() - setStatsStore(stats) + const fpsTracker = new FpsTracker(); + const stats = createStatsStore(); + setStatsStore(stats); // Bench mode: when set, append per-frame phase timings as JSONL for // offline analysis by bench/repl-scroll.ts. Captures the full TUI // render pipeline (yoga → screen buffer → diff → optimize → stdout) // so perf work on any phase can be validated against real user flows. - const frameTimingLogPath = process.env.CLAUDE_CODE_FRAME_TIMING_LOG + const frameTimingLogPath = process.env.CLAUDE_CODE_FRAME_TIMING_LOG; return { getFpsMetrics: () => fpsTracker.getMetrics(), stats, renderOptions: { ...baseOptions, onFrame: event => { - fpsTracker.record(event.durationMs) - stats.observe('frame_duration_ms', event.durationMs) + fpsTracker.record(event.durationMs); + stats.observe('frame_duration_ms', event.durationMs); if (frameTimingLogPath && event.phases) { // Bench-only env-var-gated path: sync write so no frames dropped // on abrupt exit. ~100 bytes at ≤60fps is negligible. rss/cpu are @@ -444,30 +388,30 @@ export function getRenderContext(exitOnCtrlC: boolean): { ...event.phases, rss: process.memoryUsage.rss(), cpu: process.cpuUsage(), - }) + '\n' + }) + '\n'; // eslint-disable-next-line custom-rules/no-sync-fs -- bench-only, sync so no frames dropped on exit - appendFileSync(frameTimingLogPath, line) + appendFileSync(frameTimingLogPath, line); } // Skip flicker reporting for terminals with synchronized output — // DEC 2026 buffers between BSU/ESU so clear+redraw is atomic. if (isSynchronizedOutputSupported()) { - return + return; } for (const flicker of event.flickers) { if (flicker.reason === 'resize') { - continue + continue; } - const now = Date.now() + const now = Date.now(); if (now - lastFlickerTime < 1000) { logEvent('tengu_flicker', { desiredHeight: flicker.desiredHeight, actualHeight: flicker.availableHeight, reason: flicker.reason, - } as unknown as Record) + } as unknown as Record); } - lastFlickerTime = now + lastFlickerTime = now; } }, }, - } + }; } From 23e4b055889cbe57e6349825980d6697fd33c869 Mon Sep 17 00:00:00 2001 From: rainhole Date: Tue, 7 Apr 2026 16:17:50 +0800 Subject: [PATCH 2/3] Publish Rain Code package and update provider model labels --- package.json | 14 +- src/components/LogoV2/AnimatedClawd.tsx | 107 ++---- src/components/LogoV2/Clawd.tsx | 240 +++++++----- src/components/LogoV2/CondensedLogo.tsx | 144 ++++--- src/components/LogoV2/LogoV2.tsx | 342 ++++++----------- src/components/LogoV2/WelcomeV2.tsx | 355 ++---------------- src/components/ModelPicker.tsx | 269 ++++++------- src/utils/logoV2Utils.ts | 4 +- .../model/__tests__/modelOptions.test.ts | 93 +++++ src/utils/model/__tests__/providers.test.ts | 282 ++++++++------ src/utils/model/modelOptions.ts | 127 +++++-- src/utils/model/providers.ts | 21 ++ 12 files changed, 896 insertions(+), 1102 deletions(-) create mode 100644 src/utils/model/__tests__/modelOptions.test.ts diff --git a/package.json b/package.json index 586b78fbe..bffc58372 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,16 @@ { - "name": "claude-code-best", + "name": "@rainhole/rain-code", "version": "1.1.0", "description": "Reverse-engineered Anthropic Claude Code CLI — interactive AI coding assistant in the terminal", "type": "module", - "author": "claude-code-best ", + "author": "rainhole", "repository": { "type": "git", - "url": "git+https://github.com/claude-code-best/claude-code.git" + "url": "git+https://github.com/rainhotel/claude-code-runable.git" }, - "homepage": "https://github.com/claude-code-best/claude-code#readme", + "homepage": "https://github.com/rainhotel/claude-code-runable#readme", "bugs": { - "url": "https://github.com/claude-code-best/claude-code/issues" + "url": "https://github.com/rainhotel/claude-code-runable/issues" }, "keywords": [ "claude", @@ -25,9 +25,13 @@ "bun": ">=1.2.0" }, "bin": { + "rain-code": "dist/cli.js", "ccb": "dist/cli.js", "claude-code-best": "dist/cli.js" }, + "publishConfig": { + "access": "public" + }, "workspaces": [ "packages/*", "packages/@ant/*" diff --git a/src/components/LogoV2/AnimatedClawd.tsx b/src/components/LogoV2/AnimatedClawd.tsx index 5ad68babb..513b98d65 100644 --- a/src/components/LogoV2/AnimatedClawd.tsx +++ b/src/components/LogoV2/AnimatedClawd.tsx @@ -1,96 +1,39 @@ -import * as React from 'react' -import { useEffect, useRef, useState } from 'react' -import { Box } from '@anthropic/ink' -import { getInitialSettings } from '../../utils/settings/settings.js' -import { Clawd, type ClawdPose } from './Clawd.js' +import * as React from 'react'; +import { useEffect, useState } from 'react'; +import { Box } from '@anthropic/ink'; +import { getInitialSettings } from '../../utils/settings/settings.js'; +import { Clawd, RAINCODE_SCENE_HEIGHT, type ClawdPose } from './Clawd.js'; -type Frame = { pose: ClawdPose; offset: number } - -/** Hold a pose for n frames (60ms each). */ -function hold(pose: ClawdPose, offset: number, frames: number): Frame[] { - return Array.from({ length: frames }, () => ({ pose, offset })) -} - -// Offset semantics: marginTop in a fixed-height-3 container. 0 = normal, -// 1 = crouched. Container height stays 3 so the layout never shifts; during -// a crouch (offset=1) Clawd's feet row dips below the container and gets -// clipped — reads as "ducking below the frame" before springing back up. - -// Click animation: crouch, then spring up with both arms raised. Twice. -const JUMP_WAVE: readonly Frame[] = [ - ...hold('default', 1, 2), // crouch - ...hold('arms-up', 0, 3), // spring! - ...hold('default', 0, 1), - ...hold('default', 1, 2), // crouch again - ...hold('arms-up', 0, 3), // spring! - ...hold('default', 0, 1), -] - -// Click animation: glance right, then left, then back. -const LOOK_AROUND: readonly Frame[] = [ - ...hold('look-right', 0, 5), - ...hold('look-left', 0, 5), - ...hold('default', 0, 1), -] - -const CLICK_ANIMATIONS: readonly (readonly Frame[])[] = [JUMP_WAVE, LOOK_AROUND] - -const IDLE: Frame = { pose: 'default', offset: 0 } -const FRAME_MS = 60 -const incrementFrame = (i: number) => i + 1 -const CLAWD_HEIGHT = 3 +const FRAMES: readonly ClawdPose[] = ['default', 'look-left', 'default', 'look-right', 'default', 'arms-up']; +const FRAME_MS = 280; /** - * Clawd with click-triggered animations (crouch-jump with arms up, or - * look-around). Container height is fixed at CLAWD_HEIGHT — same footprint - * as a bare `` — so the surrounding layout never shifts. During a - * crouch only the feet row clips (see comment above). Click only fires when - * mouse tracking is enabled (i.e. inside `` / fullscreen); - * elsewhere this renders and behaves identically to plain ``. + * A lightweight ambient animation for the startup scene. The scene gently + * cycles rain positions and a soft solar pulse so the header feels alive + * without needing a click target. */ export function AnimatedClawd(): React.ReactNode { - const { pose, bounceOffset, onClick } = useClawdAnimation() + const pose = useClawdAnimation(); return ( - - + + - ) + ); } -function useClawdAnimation(): { - pose: ClawdPose - bounceOffset: number - onClick: () => void -} { - // Read once at mount — no useSettings() subscription, since that would - // re-render on any settings change. - const [reducedMotion] = useState( - () => getInitialSettings().prefersReducedMotion ?? false, - ) - const [frameIndex, setFrameIndex] = useState(-1) - const sequenceRef = useRef(JUMP_WAVE) - - const onClick = () => { - if (reducedMotion || frameIndex !== -1) return - sequenceRef.current = - CLICK_ANIMATIONS[Math.floor(Math.random() * CLICK_ANIMATIONS.length)]! - setFrameIndex(0) - } +function useClawdAnimation(): ClawdPose { + const [reducedMotion] = useState(() => getInitialSettings().prefersReducedMotion ?? false); + const [frameIndex, setFrameIndex] = useState(0); useEffect(() => { - if (frameIndex === -1) return - if (frameIndex >= sequenceRef.current.length) { - setFrameIndex(-1) - return - } - const timer = setTimeout(setFrameIndex, FRAME_MS, incrementFrame) - return () => clearTimeout(timer) - }, [frameIndex]) - - const seq = sequenceRef.current - const current = - frameIndex >= 0 && frameIndex < seq.length ? seq[frameIndex]! : IDLE - return { pose: current.pose, bounceOffset: current.offset, onClick } + if (reducedMotion) return; + const timer = setInterval(() => { + setFrameIndex(current => (current + 1) % FRAMES.length); + }, FRAME_MS); + return () => clearInterval(timer); + }, [reducedMotion]); + + return reducedMotion ? 'default' : (FRAMES[frameIndex] ?? 'default'); } diff --git a/src/components/LogoV2/Clawd.tsx b/src/components/LogoV2/Clawd.tsx index 6969466bc..3cdead00a 100644 --- a/src/components/LogoV2/Clawd.tsx +++ b/src/components/LogoV2/Clawd.tsx @@ -1,98 +1,168 @@ -import * as React from 'react' -import { Box, Text } from '@anthropic/ink' -import { env } from '../../utils/env.js' +import * as React from 'react'; +import { Box, Text } from '@anthropic/ink'; -export type ClawdPose = - | 'default' - | 'arms-up' // both arms raised (used during jump) - | 'look-left' // both pupils shifted left - | 'look-right' // both pupils shifted right +export type ClawdPose = 'default' | 'arms-up' | 'look-left' | 'look-right'; + +export const RAINCODE_SCENE_WIDTH = 26; +export const RAINCODE_SCENE_HEIGHT = 5; type Props = { - pose?: ClawdPose -} + pose?: ClawdPose; +}; -// Standard-terminal pose fragments. Each row is split into segments so we can -// vary only the parts that change (eyes, arms) while keeping the body/bg spans -// stable. All poses end up 9 cols wide. -// -// arms-up: the row-2 arm shapes (▝▜ / ▛▘) move to row 1 as their -// bottom-heavy mirrors (▗▟ / ▙▖) — same silhouette, one row higher. -// -// look-* use top-quadrant eye chars (▙/▟) so both eyes change from the -// default (▛/▜, bottom pupils) — otherwise only one eye would appear to move. -type Segments = { - /** row 1 left (no bg): optional raised arm + side */ - r1L: string - /** row 1 eyes (with bg): left-eye, forehead, right-eye */ - r1E: string - /** row 1 right (no bg): side + optional raised arm */ - r1R: string - /** row 2 left (no bg): arm + body curve */ - r2L: string - /** row 2 right (no bg): body curve + arm */ - r2R: string -} +type Segment = { + color?: string; + text: string; +}; -const POSES: Record = { - default: { r1L: ' ▐', r1E: '▛███▜', r1R: '▌', r2L: '▝▜', r2R: '▛▘' }, - 'look-left': { r1L: ' ▐', r1E: '▟███▟', r1R: '▌', r2L: '▝▜', r2R: '▛▘' }, - 'look-right': { r1L: ' ▐', r1E: '▙███▙', r1R: '▌', r2L: '▝▜', r2R: '▛▘' }, - 'arms-up': { r1L: '▗▟', r1E: '▛███▜', r1R: '▙▖', r2L: ' ▜', r2R: '▛ ' }, -} +type Scene = Segment[][]; -// Apple Terminal uses a bg-fill trick (see below), so only eye poses make -// sense. Arm poses fall back to default. -const APPLE_EYES: Record = { - default: ' ▗ ▖ ', - 'look-left': ' ▘ ▘ ', - 'look-right': ' ▝ ▝ ', - 'arms-up': ' ▗ ▖ ', -} +const SCENES: Record = { + default: [ + [{ color: 'chromeYellow', text: ' \\ | / ' }, { text: ' ' }, { color: 'rainbow_red', text: '╭──────────╮' }], + [ + { color: 'chromeYellow', text: ' \\*/ ' }, + { text: ' ' }, + { color: 'rainbow_orange', text: '╭──╯' }, + { color: 'rainbow_yellow', text: '╭──────╮' }, + { color: 'rainbow_green', text: '╰──╮' }, + ], + [ + { color: 'chromeYellow', text: ' /_\\ ' }, + { text: ' ' }, + { color: 'rainbow_blue', text: '╰──╮' }, + { color: 'rainbow_indigo', text: '╰──────╯' }, + { color: 'rainbow_violet', text: '╭──╯' }, + ], + [ + { text: ' ' }, + { color: 'rainbow_blue', text: '╲' }, + { text: ' ' }, + { color: 'rainbow_indigo', text: '╲' }, + { text: ' ' }, + { color: 'rainbow_blue', text: '╲' }, + ], + [ + { text: ' ' }, + { color: 'rainbow_indigo', text: '╲' }, + { text: ' ' }, + { color: 'rainbow_blue', text: '╲' }, + ], + ], + 'look-left': [ + [{ color: 'chromeYellow', text: ' \\ | / ' }, { text: ' ' }, { color: 'rainbow_red', text: '╭──────────╮' }], + [ + { color: 'chromeYellow', text: ' \\*/ ' }, + { text: ' ' }, + { color: 'rainbow_orange', text: '╭──╯' }, + { color: 'rainbow_yellow', text: '╭──────╮' }, + { color: 'rainbow_green', text: '╰──╮' }, + ], + [ + { color: 'chromeYellow', text: ' /_\\ ' }, + { text: ' ' }, + { color: 'rainbow_blue', text: '╰──╮' }, + { color: 'rainbow_indigo', text: '╰──────╯' }, + { color: 'rainbow_violet', text: '╭──╯' }, + ], + [ + { text: ' ' }, + { color: 'rainbow_blue', text: '╲' }, + { text: ' ' }, + { color: 'rainbow_indigo', text: '╲' }, + { text: ' ' }, + { color: 'rainbow_blue', text: '╲' }, + ], + [ + { text: ' ' }, + { color: 'rainbow_indigo', text: '╲' }, + { text: ' ' }, + { color: 'rainbow_blue', text: '╲' }, + { text: ' ' }, + { color: 'rainbow_indigo', text: '╲' }, + ], + ], + 'look-right': [ + [{ color: 'chromeYellow', text: ' \\ | / ' }, { text: ' ' }, { color: 'rainbow_red', text: '╭──────────╮' }], + [ + { color: 'chromeYellow', text: ' \\*/ ' }, + { text: ' ' }, + { color: 'rainbow_orange', text: '╭──╯' }, + { color: 'rainbow_yellow', text: '╭──────╮' }, + { color: 'rainbow_green', text: '╰──╮' }, + ], + [ + { color: 'chromeYellow', text: ' /_\\ ' }, + { text: ' ' }, + { color: 'rainbow_blue', text: '╰──╮' }, + { color: 'rainbow_indigo', text: '╰──────╯' }, + { color: 'rainbow_violet', text: '╭──╯' }, + ], + [ + { text: ' ' }, + { color: 'rainbow_blue', text: '╲' }, + { text: ' ' }, + { color: 'rainbow_indigo', text: '╲' }, + { text: ' ' }, + { color: 'rainbow_blue', text: '╲' }, + ], + [ + { text: ' ' }, + { color: 'rainbow_indigo', text: '╲' }, + { text: ' ' }, + { color: 'rainbow_blue', text: '╲' }, + ], + ], + 'arms-up': [ + [{ color: 'chromeYellow', text: ' \\ .*. / ' }, { text: ' ' }, { color: 'rainbow_red', text: '╭──────────╮' }], + [ + { color: 'chromeYellow', text: ' ' }, + { text: ' ' }, + { color: 'rainbow_orange', text: '╭──╯' }, + { color: 'rainbow_yellow', text: '╭──────╮' }, + { color: 'rainbow_green', text: '╰──╮' }, + ], + [ + { color: 'chromeYellow', text: ' /_\\ ' }, + { text: ' ' }, + { color: 'rainbow_blue', text: '╰──╮' }, + { color: 'rainbow_indigo', text: '╰──────╯' }, + { color: 'rainbow_violet', text: '╭──╯' }, + ], + [ + { text: ' ' }, + { color: 'rainbow_blue', text: '╲' }, + { text: ' ' }, + { color: 'rainbow_indigo', text: '╲' }, + { text: ' ' }, + { color: 'rainbow_blue', text: '╲' }, + { text: ' ' }, + { color: 'rainbow_indigo', text: '╲' }, + ], + [ + { text: ' ' }, + { color: 'rainbow_blue', text: '╲' }, + { text: ' ' }, + { color: 'rainbow_indigo', text: '╲' }, + { text: ' ' }, + { color: 'rainbow_blue', text: '╲' }, + ], + ], +}; export function Clawd({ pose = 'default' }: Props = {}): React.ReactNode { - if (env.terminal === 'Apple_Terminal') { - return - } - const p = POSES[pose] + const scene = SCENES[pose]; return ( - - {p.r1L} - - {p.r1E} - - {p.r1R} - - - {p.r2L} - - █████ - - {p.r2R} - - - {' '}▘▘ ▝▝{' '} - - - ) -} - -function AppleTerminalClawd({ pose }: { pose: ClawdPose }): React.ReactNode { - // Apple's Terminal renders vertical space between chars by default. - // It does NOT render vertical space between background colors - // so we use background color to draw the main shape. - return ( - - - - - {APPLE_EYES[pose]} + {scene.map((row, rowIndex) => ( + + {row.map((segment, segmentIndex) => ( + + {segment.text} + + ))} - - - {' '.repeat(7)} - ▘▘ ▝▝ + ))} - ) + ); } diff --git a/src/components/LogoV2/CondensedLogo.tsx b/src/components/LogoV2/CondensedLogo.tsx index eb048ec2d..85a6f0e1b 100644 --- a/src/components/LogoV2/CondensedLogo.tsx +++ b/src/components/LogoV2/CondensedLogo.tsx @@ -1,83 +1,70 @@ -import * as React from 'react' -import { type ReactNode, useEffect } from 'react' -import { useMainLoopModel } from '../../hooks/useMainLoopModel.js' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { Box, Text, stringWidth } from '@anthropic/ink' -import { useAppState } from '../../state/AppState.js' -import { getEffortSuffix } from '../../utils/effort.js' -import { truncate } from '../../utils/format.js' -import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js' -import { - formatModelAndBilling, - getLogoDisplayData, - truncatePath, -} from '../../utils/logoV2Utils.js' -import { renderModelSetting } from '../../utils/model/model.js' -import { OffscreenFreeze } from '../OffscreenFreeze.js' -import { AnimatedClawd } from './AnimatedClawd.js' -import { Clawd } from './Clawd.js' -import { - GuestPassesUpsell, - incrementGuestPassesSeenCount, - useShowGuestPassesUpsell, -} from './GuestPassesUpsell.js' +import * as React from 'react'; +import { type ReactNode, useEffect } from 'react'; +import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { Box, Text, stringWidth } from '@anthropic/ink'; +import { useAppState } from '../../state/AppState.js'; +import { getEffortSuffix } from '../../utils/effort.js'; +import { truncate } from '../../utils/format.js'; +import { formatModelAndBilling, getLogoDisplayData, truncatePath } from '../../utils/logoV2Utils.js'; +import { renderModelSetting } from '../../utils/model/model.js'; +import { OffscreenFreeze } from '../OffscreenFreeze.js'; +import { AnimatedClawd } from './AnimatedClawd.js'; +import { RAINCODE_SCENE_WIDTH } from './Clawd.js'; +import { GuestPassesUpsell, incrementGuestPassesSeenCount, useShowGuestPassesUpsell } from './GuestPassesUpsell.js'; import { incrementOverageCreditUpsellSeenCount, OverageCreditUpsell, useShowOverageCreditUpsell, -} from './OverageCreditUpsell.js' +} from './OverageCreditUpsell.js'; export function CondensedLogo(): ReactNode { - const { columns } = useTerminalSize() - const agent = useAppState(s => s.agent) - const effortValue = useAppState(s => s.effortValue) - const model = useMainLoopModel() - const modelDisplayName = renderModelSetting(model) - const { version, cwd, billingType, agentName: agentNameFromSettings } = getLogoDisplayData() + const { columns } = useTerminalSize(); + const agent = useAppState(s => s.agent); + const effortValue = useAppState(s => s.effortValue); + const model = useMainLoopModel(); + const modelDisplayName = renderModelSetting(model); + const { version, cwd, billingType, agentName: agentNameFromSettings } = getLogoDisplayData(); // Prefer AppState.agent (set from --agent CLI flag) over settings - const agentName = agent ?? agentNameFromSettings - const showGuestPassesUpsell = useShowGuestPassesUpsell() - const showOverageCreditUpsell = useShowOverageCreditUpsell() + const agentName = agent ?? agentNameFromSettings; + const showGuestPassesUpsell = useShowGuestPassesUpsell(); + const showOverageCreditUpsell = useShowOverageCreditUpsell(); useEffect(() => { if (showGuestPassesUpsell) { - incrementGuestPassesSeenCount() + incrementGuestPassesSeenCount(); } - }, [showGuestPassesUpsell]) + }, [showGuestPassesUpsell]); useEffect(() => { if (showOverageCreditUpsell && !showGuestPassesUpsell) { - incrementOverageCreditUpsellSeenCount() + incrementOverageCreditUpsellSeenCount(); } - }, [showOverageCreditUpsell, showGuestPassesUpsell]) + }, [showOverageCreditUpsell, showGuestPassesUpsell]); // Calculate available width for text content - // Account for: condensed clawd width (11 chars) + gap (2) + padding (2) = 15 chars - const textWidth = Math.max(columns - 15, 20) + // Account for: animated rain scene width + gap + padding + const textWidth = Math.max(columns - (RAINCODE_SCENE_WIDTH + 4), 20); - // Truncate version to fit within available width, accounting for "Claude Code v" prefix - const versionPrefix = 'Claude Code v' - const truncatedVersion = truncate( - version, - Math.max(textWidth - versionPrefix.length, 6), - ) + // Truncate version to fit within available width, accounting for "Raincode v" prefix + const versionPrefix = 'Raincode v'; + const truncatedVersion = truncate(version, Math.max(textWidth - versionPrefix.length, 6)); - const effortSuffix = getEffortSuffix(model, effortValue) - const { shouldSplit, truncatedModel, truncatedBilling } = - formatModelAndBilling( - modelDisplayName + effortSuffix, - billingType, - textWidth, - ) + const effortSuffix = getEffortSuffix(model, effortValue); + const { shouldSplit, truncatedModel, truncatedBilling } = formatModelAndBilling( + modelDisplayName + effortSuffix, + billingType, + textWidth, + ); // Truncate path, accounting for agent name if present - const separator = ' · ' - const atPrefix = '@' + const separator = ' · '; + const atPrefix = '@'; const cwdAvailableWidth = agentName ? textWidth - atPrefix.length - stringWidth(agentName) - separator.length - : textWidth - const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10)) + : textWidth; + const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10)); // OffscreenFreeze: the logo sits at the top of the message list and is the // first thing to enter scrollback. useMainLoopModel() subscribes to model @@ -86,33 +73,28 @@ export function CondensedLogo(): ReactNode { return ( - {isFullscreenEnvEnabled() ? : } + - {/* Info */} - - - Claude Code{' '} - v{truncatedVersion} - - {shouldSplit ? ( - <> - {truncatedModel} - {truncatedBilling} - - ) : ( - - {truncatedModel} · {truncatedBilling} + {/* Info */} + + + Raincode v{truncatedVersion} - )} - - {agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd} - - {showGuestPassesUpsell && } - {!showGuestPassesUpsell && showOverageCreditUpsell && ( - - )} - + {shouldSplit ? ( + <> + {truncatedModel} + {truncatedBilling} + + ) : ( + + {truncatedModel} · {truncatedBilling} + + )} + {agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd} + {showGuestPassesUpsell && } + {!showGuestPassesUpsell && showOverageCreditUpsell && } + - ) + ); } diff --git a/src/components/LogoV2/LogoV2.tsx b/src/components/LogoV2/LogoV2.tsx index e457f1084..6049695d9 100644 --- a/src/components/LogoV2/LogoV2.tsx +++ b/src/components/LogoV2/LogoV2.tsx @@ -1,7 +1,7 @@ // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered -import * as React from 'react' -import { Box, Text, color, stringWidth } from '@anthropic/ink' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import * as React from 'react'; +import { Box, Text, color, stringWidth } from '@anthropic/ink'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; import { getLayoutMode, calculateLayoutDimensions, @@ -11,44 +11,37 @@ import { getRecentActivitySync, getRecentReleaseNotesSync, getLogoDisplayData, -} from '../../utils/logoV2Utils.js' -import { truncate } from '../../utils/format.js' -import { getDisplayPath } from '../../utils/file.js' -import { Clawd } from './Clawd.js' -import { FeedColumn } from './FeedColumn.js' +} from '../../utils/logoV2Utils.js'; +import { truncate } from '../../utils/format.js'; +import { getDisplayPath } from '../../utils/file.js'; +import { Clawd } from './Clawd.js'; +import { FeedColumn } from './FeedColumn.js'; import { createRecentActivityFeed, createWhatsNewFeed, createProjectOnboardingFeed, createGuestPassesFeed, -} from './feedConfigs.js' -import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js' -import { resolveThemeSetting } from 'src/utils/systemTheme.js' -import { getInitialSettings } from 'src/utils/settings/settings.js' -import { - isDebugMode, - isDebugToStdErr, - getDebugLogPath, -} from 'src/utils/debug.js' -import { useEffect, useState } from 'react' +} from './feedConfigs.js'; +import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js'; +import { resolveThemeSetting } from 'src/utils/systemTheme.js'; +import { getInitialSettings } from 'src/utils/settings/settings.js'; +import { isDebugMode, isDebugToStdErr, getDebugLogPath } from 'src/utils/debug.js'; +import { useEffect, useState } from 'react'; import { getSteps, shouldShowProjectOnboarding, incrementProjectOnboardingSeenCount, -} from '../../projectOnboardingState.js' -import { CondensedLogo } from './CondensedLogo.js' -import { OffscreenFreeze } from '../OffscreenFreeze.js' -import { checkForReleaseNotesSync } from '../../utils/releaseNotes.js' -import { getDumpPromptsPath } from 'src/services/api/dumpPrompts.js' -import { isEnvTruthy } from 'src/utils/envUtils.js' -import { - getStartupPerfLogPath, - isDetailedProfilingEnabled, -} from 'src/utils/startupProfiler.js' -import { EmergencyTip } from './EmergencyTip.js' -import { VoiceModeNotice } from './VoiceModeNotice.js' -import { Opus1mMergeNotice } from './Opus1mMergeNotice.js' -import { feature } from 'bun:bundle' +} from '../../projectOnboardingState.js'; +import { CondensedLogo } from './CondensedLogo.js'; +import { OffscreenFreeze } from '../OffscreenFreeze.js'; +import { checkForReleaseNotesSync } from '../../utils/releaseNotes.js'; +import { getDumpPromptsPath } from 'src/services/api/dumpPrompts.js'; +import { isEnvTruthy } from 'src/utils/envUtils.js'; +import { getStartupPerfLogPath, isDetailedProfilingEnabled } from 'src/utils/startupProfiler.js'; +import { EmergencyTip } from './EmergencyTip.js'; +import { VoiceModeNotice } from './VoiceModeNotice.js'; +import { Opus1mMergeNotice } from './Opus1mMergeNotice.js'; +import { feature } from 'bun:bundle'; // Conditional require so ChannelsNotice.tsx tree-shakes when both flags are // false. A module-scope helper component inside a feature() ternary does NOT @@ -59,128 +52,99 @@ import { feature } from 'bun:bundle' const ChannelsNoticeModule = feature('KAIROS') || feature('KAIROS_CHANNELS') ? (require('./ChannelsNotice.js') as typeof import('./ChannelsNotice.js')) - : null + : null; /* eslint-enable @typescript-eslint/no-require-imports */ -import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js' -import { - useShowGuestPassesUpsell, - incrementGuestPassesSeenCount, -} from './GuestPassesUpsell.js' +import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js'; +import { useShowGuestPassesUpsell, incrementGuestPassesSeenCount } from './GuestPassesUpsell.js'; import { useShowOverageCreditUpsell, incrementOverageCreditUpsellSeenCount, createOverageCreditFeed, -} from './OverageCreditUpsell.js' -import { plural } from '../../utils/stringUtils.js' -import { useAppState } from '../../state/AppState.js' -import { getEffortSuffix } from '../../utils/effort.js' -import { useMainLoopModel } from '../../hooks/useMainLoopModel.js' -import { renderModelSetting } from '../../utils/model/model.js' +} from './OverageCreditUpsell.js'; +import { plural } from '../../utils/stringUtils.js'; +import { useAppState } from '../../state/AppState.js'; +import { getEffortSuffix } from '../../utils/effort.js'; +import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'; +import { renderModelSetting } from '../../utils/model/model.js'; +import { AnimatedClawd } from './AnimatedClawd.js'; -const LEFT_PANEL_MAX_WIDTH = 50 +const LEFT_PANEL_MAX_WIDTH = 50; export function LogoV2(): React.ReactNode { - const activities = getRecentActivitySync() - const username = getGlobalConfig().oauthAccount?.displayName ?? '' + const activities = getRecentActivitySync(); + const username = getGlobalConfig().oauthAccount?.displayName ?? ''; - const { columns } = useTerminalSize() - const showOnboarding = shouldShowProjectOnboarding() - const showSandboxStatus = SandboxManager.isSandboxingEnabled() - const showGuestPassesUpsell = useShowGuestPassesUpsell() - const showOverageCreditUpsell = useShowOverageCreditUpsell() - const agent = useAppState(s => s.agent) - const effortValue = useAppState(s => s.effortValue) + const { columns } = useTerminalSize(); + const showOnboarding = shouldShowProjectOnboarding(); + const showSandboxStatus = SandboxManager.isSandboxingEnabled(); + const showGuestPassesUpsell = useShowGuestPassesUpsell(); + const showOverageCreditUpsell = useShowOverageCreditUpsell(); + const agent = useAppState(s => s.agent); + const effortValue = useAppState(s => s.effortValue); - const config = getGlobalConfig() + const config = getGlobalConfig(); - let changelog: string[] + let changelog: string[]; try { - changelog = getRecentReleaseNotesSync(3) + changelog = getRecentReleaseNotesSync(3); } catch { - changelog = [] + changelog = []; } // Get company announcements and select one: // - First startup (numStartups === 1): show first announcement // - All other startups: randomly select from announcements const [announcement] = useState(() => { - const announcements = getInitialSettings().companyAnnouncements - if (!announcements || announcements.length === 0) return undefined + const announcements = getInitialSettings().companyAnnouncements; + if (!announcements || announcements.length === 0) return undefined; return config.numStartups === 1 ? announcements[0] - : announcements[Math.floor(Math.random() * announcements.length)] - }) - const { hasReleaseNotes } = checkForReleaseNotesSync( - config.lastReleaseNotesSeen, - ) + : announcements[Math.floor(Math.random() * announcements.length)]; + }); + const { hasReleaseNotes } = checkForReleaseNotesSync(config.lastReleaseNotesSeen); useEffect(() => { - const currentConfig = getGlobalConfig() + const currentConfig = getGlobalConfig(); if (currentConfig.lastReleaseNotesSeen === MACRO.VERSION) { - return + return; } saveGlobalConfig(current => { - if (current.lastReleaseNotesSeen === MACRO.VERSION) return current - return { ...current, lastReleaseNotesSeen: MACRO.VERSION } - }) + if (current.lastReleaseNotesSeen === MACRO.VERSION) return current; + return { ...current, lastReleaseNotesSeen: MACRO.VERSION }; + }); if (showOnboarding) { - incrementProjectOnboardingSeenCount() + incrementProjectOnboardingSeenCount(); } - }, [config, showOnboarding]) + }, [config, showOnboarding]); // In condensed mode (early-return below renders ), // CondensedLogo's own useEffect handles the impression count. Skipping // here avoids double-counting since hooks fire before the early return. - const isCondensedMode = - !hasReleaseNotes && - !showOnboarding && - !isEnvTruthy(process.env.CLAUDE_CODE_FORCE_FULL_LOGO) + const isCondensedMode = !hasReleaseNotes && !showOnboarding && !isEnvTruthy(process.env.CLAUDE_CODE_FORCE_FULL_LOGO); useEffect(() => { if (showGuestPassesUpsell && !showOnboarding && !isCondensedMode) { - incrementGuestPassesSeenCount() + incrementGuestPassesSeenCount(); } - }, [showGuestPassesUpsell, showOnboarding, isCondensedMode]) + }, [showGuestPassesUpsell, showOnboarding, isCondensedMode]); useEffect(() => { - if ( - showOverageCreditUpsell && - !showOnboarding && - !showGuestPassesUpsell && - !isCondensedMode - ) { - incrementOverageCreditUpsellSeenCount() + if (showOverageCreditUpsell && !showOnboarding && !showGuestPassesUpsell && !isCondensedMode) { + incrementOverageCreditUpsellSeenCount(); } - }, [ - showOverageCreditUpsell, - showOnboarding, - showGuestPassesUpsell, - isCondensedMode, - ]) + }, [showOverageCreditUpsell, showOnboarding, showGuestPassesUpsell, isCondensedMode]); - const model = useMainLoopModel() - const fullModelDisplayName = renderModelSetting(model) - const { - version, - cwd, - billingType, - agentName: agentNameFromSettings, - } = getLogoDisplayData() + const model = useMainLoopModel(); + const fullModelDisplayName = renderModelSetting(model); + const { version, cwd, billingType, agentName: agentNameFromSettings } = getLogoDisplayData(); // Prefer AppState.agent (set from --agent CLI flag) over settings - const agentName = agent ?? agentNameFromSettings + const agentName = agent ?? agentNameFromSettings; // -20 to account for the max length of subscription name " · Claude Enterprise". - const effortSuffix = getEffortSuffix(model, effortValue) - const modelDisplayName = truncate( - fullModelDisplayName + effortSuffix, - LEFT_PANEL_MAX_WIDTH - 20, - ) + const effortSuffix = getEffortSuffix(model, effortValue); + const modelDisplayName = truncate(fullModelDisplayName + effortSuffix, LEFT_PANEL_MAX_WIDTH - 20); // Show condensed logo if no new changelog and not showing onboarding and not forcing full logo - if ( - !hasReleaseNotes && - !showOnboarding && - !isEnvTruthy(process.env.CLAUDE_CODE_FORCE_FULL_LOGO) - ) { + if (!hasReleaseNotes && !showOnboarding && !isEnvTruthy(process.env.CLAUDE_CODE_FORCE_FULL_LOGO)) { return ( <> @@ -190,17 +154,13 @@ export function LogoV2(): React.ReactNode { {isDebugMode() && ( Debug mode enabled - - Logging to: {isDebugToStdErr() ? 'stderr' : getDebugLogPath()} - + Logging to: {isDebugToStdErr() ? 'stderr' : getDebugLogPath()} )} {process.env.CLAUDE_CODE_TMUX_SESSION && ( - - tmux session: {process.env.CLAUDE_CODE_TMUX_SESSION} - + tmux session: {process.env.CLAUDE_CODE_TMUX_SESSION} {process.env.CLAUDE_CODE_TMUX_PREFIX_CONFLICTS ? `Detach: ${process.env.CLAUDE_CODE_TMUX_PREFIX} ${process.env.CLAUDE_CODE_TMUX_PREFIX} d (press prefix twice - Claude uses ${process.env.CLAUDE_CODE_TMUX_PREFIX})` @@ -211,9 +171,7 @@ export function LogoV2(): React.ReactNode { {announcement && ( {!process.env.IS_DEMO && config.oauthAccount?.organizationName && ( - - Message from {config.oauthAccount.organizationName}: - + Message from {config.oauthAccount.organizationName}: )} {announcement} @@ -226,51 +184,41 @@ export function LogoV2(): React.ReactNode { {process.env.USER_TYPE === 'ant' && !process.env.DEMO_VERSION && ( [ANT-ONLY] Logs: - - API calls: {getDisplayPath(getDumpPromptsPath())} - - - Debug logs: {getDisplayPath(getDebugLogPath())} - + API calls: {getDisplayPath(getDumpPromptsPath())} + Debug logs: {getDisplayPath(getDebugLogPath())} {isDetailedProfilingEnabled() && ( - - Startup Perf: {getDisplayPath(getStartupPerfLogPath())} - + Startup Perf: {getDisplayPath(getStartupPerfLogPath())} )} )} {process.env.USER_TYPE === 'ant' && } {process.env.USER_TYPE === 'ant' && } - ) + ); } // Calculate layout and display values - const layoutMode = getLayoutMode(columns) + const layoutMode = getLayoutMode(columns); - const userTheme = resolveThemeSetting(getGlobalConfig().theme) - const borderTitle = ` ${color('claude', userTheme)('Claude Code')} ${color('inactive', userTheme)(`v${version}`)} ` - const compactBorderTitle = color('claude', userTheme)(' Claude Code ') + const userTheme = resolveThemeSetting(getGlobalConfig().theme); + const borderTitle = ` ${color('rainbow_blue', userTheme)('Raincode')} ${color('inactive', userTheme)(`v${version}`)} `; + const compactBorderTitle = color('rainbow_blue', userTheme)(' Raincode '); // Early return for compact mode if (layoutMode === 'compact') { - const layoutWidth = 4 // border + padding - let welcomeMessage = formatWelcomeMessage(username) + const layoutWidth = 4; // border + padding + let welcomeMessage = formatWelcomeMessage(username); if (stringWidth(welcomeMessage) > columns - layoutWidth) { - welcomeMessage = formatWelcomeMessage(null) + welcomeMessage = formatWelcomeMessage(null); } // Calculate cwd width accounting for agent name if present - const separator = ' · ' - const atPrefix = '@' + const separator = ' · '; + const atPrefix = '@'; const cwdAvailableWidth = agentName - ? columns - - layoutWidth - - atPrefix.length - - stringWidth(agentName) - - separator.length - : columns - layoutWidth - const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10)) + ? columns - layoutWidth - atPrefix.length - stringWidth(agentName) - separator.length + : columns - layoutWidth; + const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10)); // OffscreenFreeze: logo is the first thing to enter scrollback; useMainLoopModel() // subscribes to model changes and getLogoDisplayData() reads cwd/subscription — // any change while in scrollback forces a full reset. @@ -280,7 +228,7 @@ export function LogoV2(): React.ReactNode { {welcomeMessage} - + {modelDisplayName} {billingType} - - {agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd} - + {agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd} @@ -308,45 +254,32 @@ export function LogoV2(): React.ReactNode { {ChannelsNoticeModule && } {showSandboxStatus && ( - - Your bash commands will be sandboxed. Disable with /sandbox. - + Your bash commands will be sandboxed. Disable with /sandbox. )} {process.env.USER_TYPE === 'ant' && } {process.env.USER_TYPE === 'ant' && } - ) + ); } - const welcomeMessage = formatWelcomeMessage(username) + const welcomeMessage = formatWelcomeMessage(username); const modelLine = !process.env.IS_DEMO && config.oauthAccount?.organizationName ? `${modelDisplayName} · ${billingType} · ${config.oauthAccount.organizationName}` - : `${modelDisplayName} · ${billingType}` + : `${modelDisplayName} · ${billingType}`; // Calculate cwd width accounting for agent name if present - const cwdSeparator = ' · ' - const cwdAtPrefix = '@' + const cwdSeparator = ' · '; + const cwdAtPrefix = '@'; const cwdAvailableWidth = agentName - ? LEFT_PANEL_MAX_WIDTH - - cwdAtPrefix.length - - stringWidth(agentName) - - cwdSeparator.length - : LEFT_PANEL_MAX_WIDTH - const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10)) - const cwdLine = agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd - const optimalLeftWidth = calculateOptimalLeftWidth( - welcomeMessage, - cwdLine, - modelLine, - ) + ? LEFT_PANEL_MAX_WIDTH - cwdAtPrefix.length - stringWidth(agentName) - cwdSeparator.length + : LEFT_PANEL_MAX_WIDTH; + const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10)); + const cwdLine = agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd; + const optimalLeftWidth = calculateOptimalLeftWidth(welcomeMessage, cwdLine, modelLine); // Calculate layout dimensions - const { leftWidth, rightWidth } = calculateLayoutDimensions( - columns, - layoutMode, - optimalLeftWidth, - ) + const { leftWidth, rightWidth } = calculateLayoutDimensions(columns, layoutMode, optimalLeftWidth); return ( <> @@ -354,7 +287,7 @@ export function LogoV2(): React.ReactNode { {/* Main content */} - + {/* Left Panel */} {welcomeMessage} - + {modelLine} @@ -393,7 +322,7 @@ export function LogoV2(): React.ReactNode { @@ -437,17 +354,13 @@ export function LogoV2(): React.ReactNode { {isDebugMode() && ( Debug mode enabled - - Logging to: {isDebugToStdErr() ? 'stderr' : getDebugLogPath()} - + Logging to: {isDebugToStdErr() ? 'stderr' : getDebugLogPath()} )} {process.env.CLAUDE_CODE_TMUX_SESSION && ( - - tmux session: {process.env.CLAUDE_CODE_TMUX_SESSION} - + tmux session: {process.env.CLAUDE_CODE_TMUX_SESSION} {process.env.CLAUDE_CODE_TMUX_PREFIX_CONFLICTS ? `Detach: ${process.env.CLAUDE_CODE_TMUX_PREFIX} ${process.env.CLAUDE_CODE_TMUX_PREFIX} d (press prefix twice - Claude uses ${process.env.CLAUDE_CODE_TMUX_PREFIX})` @@ -458,18 +371,14 @@ export function LogoV2(): React.ReactNode { {announcement && ( {!process.env.IS_DEMO && config.oauthAccount?.organizationName && ( - - Message from {config.oauthAccount.organizationName}: - + Message from {config.oauthAccount.organizationName}: )} {announcement} )} {showSandboxStatus && ( - - Your bash commands will be sandboxed. Disable with /sandbox. - + Your bash commands will be sandboxed. Disable with /sandbox. )} {process.env.USER_TYPE === 'ant' && !process.env.DEMO_VERSION && ( @@ -480,20 +389,15 @@ export function LogoV2(): React.ReactNode { {process.env.USER_TYPE === 'ant' && !process.env.DEMO_VERSION && ( [ANT-ONLY] Logs: - - API calls: {getDisplayPath(getDumpPromptsPath())} - + API calls: {getDisplayPath(getDumpPromptsPath())} Debug logs: {getDisplayPath(getDebugLogPath())} {isDetailedProfilingEnabled() && ( - - Startup Perf: {getDisplayPath(getStartupPerfLogPath())} - + Startup Perf: {getDisplayPath(getStartupPerfLogPath())} )} )} {process.env.USER_TYPE === 'ant' && } {process.env.USER_TYPE === 'ant' && } - ) + ); } - diff --git a/src/components/LogoV2/WelcomeV2.tsx b/src/components/LogoV2/WelcomeV2.tsx index ccbbcbf44..d878e96c8 100644 --- a/src/components/LogoV2/WelcomeV2.tsx +++ b/src/components/LogoV2/WelcomeV2.tsx @@ -1,326 +1,51 @@ -import React from 'react' -import { Box, Text, useTheme } from '@anthropic/ink' -import { env } from '../../utils/env.js' +import React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { AnimatedClawd } from './AnimatedClawd.js'; -const WELCOME_V2_WIDTH = 58 +const WELCOME_V2_WIDTH = 58; +const WELCOME_SEPARATOR = '·'.repeat(30); export function WelcomeV2(): React.ReactNode { - const [theme] = useTheme() - const welcomeMessage = 'Welcome to Claude Code' - - if (env.terminal === 'Apple_Terminal') { - return ( - - ) - } - - if (['light', 'light-daltonized', 'light-ansi'].includes(theme)) { - return ( - - - - {welcomeMessage} - v{MACRO.VERSION} - - - {'…………………………………………………………………………………………………………………………………………………………'} - - - {' '} - - - {' '} - - - {' '} - - - {' ░░░░░░ '} - - - {' ░░░ ░░░░░░░░░░ '} - - - {' ░░░░░░░░░░░░░░░░░░░ '} - - - {' '} - - - {' ░░░░'} - {' ██ '} - - - {' ░░░░░░░░░░'} - {' ██▒▒██ '} - - - {' ▒▒ ██ ▒'} - - - {' '} - █████████ - {' ▒▒░░▒▒ ▒ ▒▒'} - - - {' '} - - ██▄█████▄██ - - {' ▒▒ ▒▒ '} - - - {' '} - █████████ - {' ░ ▒ '} - - - {'…………………'} - {'█ █ █ █'} - {'……………………………………………………………………░…………………………▒…………'} - - - - ) - } - return ( - + - - {welcomeMessage} - v{MACRO.VERSION} - - - {'…………………………………………………………………………………………………………………………………………………………'} - - - {' '} - - - {' * █████▓▓░ '} - - - {' * ███▓░ ░░ '} - - - {' ░░░░░░ ███▓░ '} - - - {' ░░░ ░░░░░░░░░░ ███▓░ '} - - - {' ░░░░░░░░░░░░░░░░░░░ '} - * - {' ██▓░░ ▓ '} - - - {' ░▓▓███▓▓░ '} - - - {' * ░░░░ '} - - - {' ░░░░░░░░ '} - - - {' ░░░░░░░░░░░░░░░░ '} - - - {' '} - █████████ - {' '} - * - - - - {' '} - ██▄█████▄██ - {' '} - * - {' '} - - - {' '} - █████████ - {' * '} - - - {'…………………'} - {'█ █ █ █'} - {'………………………………………………………………………………………………………………'} - + Welcome to + + + v{MACRO.VERSION} + {WELCOME_SEPARATOR} + sunlight, rain, and a calmer terminal + + + + light after the storm, flow after the fix + soft rain on one side, warm sun on the other + + - ) -} - -type AppleTerminalWelcomeV2Props = { - theme: string - welcomeMessage: string + ); } -function AppleTerminalWelcomeV2({ - theme, - welcomeMessage, -}: AppleTerminalWelcomeV2Props): React.ReactNode { - const isLightTheme = ['light', 'light-daltonized', 'light-ansi'].includes( - theme, - ) - - if (isLightTheme) { - return ( - - - - {welcomeMessage} - v{MACRO.VERSION} - - - {'…………………………………………………………………………………………………………………………………………………………'} - - - {' '} - - - {' '} - - - {' '} - - - {' ░░░░░░ '} - - - {' ░░░ ░░░░░░░░░░ '} - - - {' ░░░░░░░░░░░░░░░░░░░ '} - - - {' '} - - - {' ░░░░'} - {' ██ '} - - - {' ░░░░░░░░░░'} - {' ██▒▒██ '} - - - {' ▒▒ ██ ▒'} - - - {' ▒▒░░▒▒ ▒ ▒▒'} - - - {' '} - - - {' '} - ▗{' '}▖{' '} - - - {' ▒▒ ▒▒ '} - - - {' '} - {' '.repeat(9)} - {' ░ ▒ '} - - - {'…………………'} - - - - {' '} - - - - {'……………………………………………………………………░…………………………▒…………'} - - - - ) - } - +function RainbowWord(): React.ReactNode { return ( - - - - {welcomeMessage} - v{MACRO.VERSION} - - - {'…………………………………………………………………………………………………………………………………………………………'} - - - {' '} - - - {' * █████▓▓░ '} - - - {' * ███▓░ ░░ '} - - - {' ░░░░░░ ███▓░ '} - - - {' ░░░ ░░░░░░░░░░ ███▓░ '} - - - {' ░░░░░░░░░░░░░░░░░░░ '} - * - {' ██▓░░ ▓ '} - - - {' ░▓▓███▓▓░ '} - - - {' * ░░░░ '} - - - {' ░░░░░░░░ '} - - - {' ░░░░░░░░░░░░░░░░ '} - - - {' '} - * - - - - {' '} - - - {' '} - ▗{' '}▖{' '} - - - {' '} - * - {' '} - - - {' '} - {' '.repeat(9)} - {' * '} - - - {'…………………'} - - - - {' '} - - - - {'………………………………………………………………………………………………………………'} - - - - ) + <> + R + a + i + n + c + o + d + e + + ); } diff --git a/src/components/ModelPicker.tsx b/src/components/ModelPicker.tsx index 4977dbbcb..c39e0f69b 100644 --- a/src/components/ModelPicker.tsx +++ b/src/components/ModelPicker.tsx @@ -1,20 +1,20 @@ -import capitalize from 'lodash-es/capitalize.js' -import * as React from 'react' -import { useCallback, useMemo, useState } from 'react' -import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js' +import capitalize from 'lodash-es/capitalize.js'; +import * as React from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' +} from 'src/services/analytics/index.js'; import { FAST_MODE_MODEL_DISPLAY, isFastModeAvailable, isFastModeCooldown, isFastModeEnabled, -} from 'src/utils/fastMode.js' -import { Box, Text } from '@anthropic/ink' -import { useKeybindings } from '../keybindings/useKeybinding.js' -import { useAppState, useSetAppState } from '../state/AppState.js' +} from 'src/utils/fastMode.js'; +import { Box, Text } from '@anthropic/ink'; +import { useKeybindings } from '../keybindings/useKeybinding.js'; +import { useAppState, useSetAppState } from '../state/AppState.js'; import { convertEffortValueToLevel, type EffortLevel, @@ -23,42 +23,40 @@ import { modelSupportsMaxEffort, resolvePickerEffortPersistence, toPersistableEffort, -} from '../utils/effort.js' +} from '../utils/effort.js'; import { getDefaultMainLoopModel, type ModelSetting, modelDisplayString, parseUserSpecifiedModel, -} from '../utils/model/model.js' -import { getModelOptions } from '../utils/model/modelOptions.js' -import { - getSettingsForSource, - updateSettingsForSource, -} from '../utils/settings/settings.js' -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' -import { Select } from './CustomSelect/index.js' -import { Byline, KeyboardShortcutHint, Pane } from '@anthropic/ink' -import { effortLevelToSymbol } from './EffortIndicator.js' +} from '../utils/model/model.js'; +import { getModelOptions } from '../utils/model/modelOptions.js'; +import { getAPIProvider, getAPIProviderDisplayName } from '../utils/model/providers.js'; +import { getSettingsForSource, updateSettingsForSource } from '../utils/settings/settings.js'; +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; +import { Select } from './CustomSelect/index.js'; +import { Byline, KeyboardShortcutHint, Pane } from '@anthropic/ink'; +import { effortLevelToSymbol } from './EffortIndicator.js'; export type Props = { - initial: string | null - sessionModel?: ModelSetting - onSelect: (model: string | null, effort: EffortLevel | undefined) => void - onCancel?: () => void - isStandaloneCommand?: boolean - showFastModeNotice?: boolean + initial: string | null; + sessionModel?: ModelSetting; + onSelect: (model: string | null, effort: EffortLevel | undefined) => void; + onCancel?: () => void; + isStandaloneCommand?: boolean; + showFastModeNotice?: boolean; /** Overrides the dim header line below "Select model". */ - headerText?: string + headerText?: string; /** * When true, skip writing effortLevel to userSettings on selection. * Used by the assistant installer wizard where the model choice is * project-scoped (written to the assistant's .claude/settings.json via * install.ts) and should not leak to the user's global ~/.claude/settings. */ - skipSettingsWrite?: boolean -} + skipSettingsWrite?: boolean; +}; -const NO_PREFERENCE = '__NO_PREFERENCE__' +const NO_PREFERENCE = '__NO_PREFERENCE__'; export function ModelPicker({ initial, @@ -70,32 +68,34 @@ export function ModelPicker({ headerText, skipSettingsWrite, }: Props): React.ReactNode { - const setAppState = useSetAppState() - const exitState = useExitOnCtrlCDWithKeybindings() - const maxVisible = 10 + const setAppState = useSetAppState(); + const exitState = useExitOnCtrlCDWithKeybindings(); + const maxVisible = 10; - const initialValue = initial === null ? NO_PREFERENCE : initial - const [focusedValue, setFocusedValue] = useState( - initialValue, - ) + const initialValue = initial === null ? NO_PREFERENCE : initial; + const [focusedValue, setFocusedValue] = useState(initialValue); - const isFastMode = useAppState(s => - isFastModeEnabled() ? s.fastMode : false, - ) + const isFastMode = useAppState(s => (isFastModeEnabled() ? s.fastMode : false)); - const [hasToggledEffort, setHasToggledEffort] = useState(false) - const effortValue = useAppState(s => s.effortValue) + const [hasToggledEffort, setHasToggledEffort] = useState(false); + const effortValue = useAppState(s => s.effortValue); const [effort, setEffort] = useState( - effortValue !== undefined - ? convertEffortValueToLevel(effortValue) - : undefined, - ) + effortValue !== undefined ? convertEffortValueToLevel(effortValue) : undefined, + ); // Memoize all derived values to prevent re-renders - const modelOptions = useMemo( - () => getModelOptions(isFastMode ?? false), - [isFastMode], - ) + const modelOptions = useMemo(() => getModelOptions(isFastMode ?? false), [isFastMode]); + const apiProvider = getAPIProvider(); + const providerDisplayName = getAPIProviderDisplayName(apiProvider); + const pickerTitle = apiProvider === 'firstParty' ? 'Select Claude model' : `Select ${providerDisplayName} model`; + const defaultHeaderText = + apiProvider === 'firstParty' + ? 'Switch between Claude models. Applies to this session and future Claude Code sessions. For other/previous model names, specify with --model.' + : `Switch between model aliases for the current ${providerDisplayName} provider. Applies to this session and future Claude Code sessions.`; + const providerHint = + apiProvider === 'firstParty' + ? null + : `The model names shown here are Claude-compatible aliases. Actual requests will be mapped to ${providerDisplayName} models.`; // Ensure the initial value is in the options list // This handles edge cases where the user's current model (e.g., 'haiku' for 3P users) @@ -109,10 +109,10 @@ export function ModelPicker({ label: modelDisplayString(initial), description: 'Current model', }, - ] + ]; } - return modelOptions - }, [modelOptions, initial]) + return modelOptions; + }, [modelOptions, initial]); const selectOptions = useMemo( () => @@ -121,58 +121,42 @@ export function ModelPicker({ value: opt.value === null ? NO_PREFERENCE : opt.value, })), [optionsWithInitial], - ) + ); const initialFocusValue = useMemo( - () => - selectOptions.some(_ => _.value === initialValue) - ? initialValue - : (selectOptions[0]?.value ?? undefined), + () => (selectOptions.some(_ => _.value === initialValue) ? initialValue : (selectOptions[0]?.value ?? undefined)), [selectOptions, initialValue], - ) - const visibleCount = Math.min(maxVisible, selectOptions.length) - const hiddenCount = Math.max(0, selectOptions.length - visibleCount) + ); + const visibleCount = Math.min(maxVisible, selectOptions.length); + const hiddenCount = Math.max(0, selectOptions.length - visibleCount); - const focusedModelName = selectOptions.find( - opt => opt.value === focusedValue, - )?.label - const focusedModel = resolveOptionModel(focusedValue) - const focusedSupportsEffort = focusedModel - ? modelSupportsEffort(focusedModel) - : false - const focusedSupportsMax = focusedModel - ? modelSupportsMaxEffort(focusedModel) - : false - const focusedDefaultEffort = getDefaultEffortLevelForOption(focusedValue) + const focusedModelName = selectOptions.find(opt => opt.value === focusedValue)?.label; + const focusedModel = resolveOptionModel(focusedValue); + const focusedSupportsEffort = focusedModel ? modelSupportsEffort(focusedModel) : false; + const focusedSupportsMax = focusedModel ? modelSupportsMaxEffort(focusedModel) : false; + const focusedDefaultEffort = getDefaultEffortLevelForOption(focusedValue); // Clamp display when 'max' is selected but the focused model doesn't support it. // resolveAppliedEffort() does the same downgrade at API-send time. - const displayEffort = - effort === 'max' && !focusedSupportsMax ? 'high' : effort + const displayEffort = effort === 'max' && !focusedSupportsMax ? 'high' : effort; const handleFocus = useCallback( (value: string) => { - setFocusedValue(value) + setFocusedValue(value); if (!hasToggledEffort && effortValue === undefined) { - setEffort(getDefaultEffortLevelForOption(value)) + setEffort(getDefaultEffortLevelForOption(value)); } }, [hasToggledEffort, effortValue], - ) + ); // Effort level cycling keybindings const handleCycleEffort = useCallback( (direction: 'left' | 'right') => { - if (!focusedSupportsEffort) return - setEffort(prev => - cycleEffortLevel( - prev ?? focusedDefaultEffort, - direction, - focusedSupportsMax, - ), - ) - setHasToggledEffort(true) + if (!focusedSupportsEffort) return; + setEffort(prev => cycleEffortLevel(prev ?? focusedDefaultEffort, direction, focusedSupportsMax)); + setHasToggledEffort(true); }, [focusedSupportsEffort, focusedSupportsMax, focusedDefaultEffort], - ) + ); useKeybindings( { @@ -180,13 +164,12 @@ export function ModelPicker({ 'modelPicker:increaseEffort': () => handleCycleEffort('right'), }, { context: 'ModelPicker' }, - ) + ); function handleSelect(value: string): void { logEvent('tengu_model_command_menu_effort', { - effort: - effort as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + effort: effort as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); if (!skipSettingsWrite) { // Prior comes from userSettings on disk — NOT merged settings (which // includes project/policy layers that must not leak into the user's @@ -198,24 +181,21 @@ export function ModelPicker({ getDefaultEffortLevelForOption(value), getSettingsForSource('userSettings')?.effortLevel, hasToggledEffort, - ) - const persistable = toPersistableEffort(effortLevel) + ); + const persistable = toPersistableEffort(effortLevel); if (persistable !== undefined) { - updateSettingsForSource('userSettings', { effortLevel: persistable }) + updateSettingsForSource('userSettings', { effortLevel: persistable }); } - setAppState(prev => ({ ...prev, effortValue: effortLevel })) + setAppState(prev => ({ ...prev, effortValue: effortLevel })); } - const selectedModel = resolveOptionModel(value) - const selectedEffort = - hasToggledEffort && selectedModel && modelSupportsEffort(selectedModel) - ? effort - : undefined + const selectedModel = resolveOptionModel(value); + const selectedEffort = hasToggledEffort && selectedModel && modelSupportsEffort(selectedModel) ? effort : undefined; if (value === NO_PREFERENCE) { - onSelect(null, selectedEffort) - return + onSelect(null, selectedEffort); + return; } - onSelect(value, selectedEffort) + onSelect(value, selectedEffort); } const content = ( @@ -223,16 +203,14 @@ export function ModelPicker({ - Select model - - - {headerText ?? - 'Switch between Claude models. Applies to this session and future Claude Code sessions. For other/previous model names, specify with --model.'} + {pickerTitle} + {headerText ?? defaultHeaderText} + {providerHint && {providerHint}} {sessionModel && ( - Currently using {modelDisplayString(sessionModel)} for this - session (set by plan mode). Selecting a model will undo this. + Currently using {modelDisplayString(sessionModel)} for this session (set by plan mode). Selecting a model + will undo this. )} @@ -259,10 +237,8 @@ export function ModelPicker({ {focusedSupportsEffort ? ( - {' '} - {capitalize(displayEffort)} effort - {displayEffort === focusedDefaultEffort ? ` (default)` : ``}{' '} - ← → to adjust + {capitalize(displayEffort)} effort + {displayEffort === focusedDefaultEffort ? ` (default)` : ``} ← → to adjust ) : ( @@ -276,16 +252,14 @@ export function ModelPicker({ showFastModeNotice ? ( - Fast mode is ON and available with{' '} - {FAST_MODE_MODEL_DISPLAY} only (/fast). Switching to other - models turn off fast mode. + Fast mode is ON and available with {FAST_MODE_MODEL_DISPLAY} only (/fast). Switching + to other models turn off fast mode. ) : isFastModeAvailable() && !isFastModeCooldown() ? ( - Use /fast to turn on Fast mode ( - {FAST_MODE_MODEL_DISPLAY} only). + Use /fast to turn on Fast mode ({FAST_MODE_MODEL_DISPLAY} only). ) : null @@ -299,68 +273,45 @@ export function ModelPicker({ ) : ( - + )} )} - ) + ); if (!isStandaloneCommand) { - return content + return content; } - return {content} + return {content}; } function resolveOptionModel(value?: string): string | undefined { - if (!value) return undefined - return value === NO_PREFERENCE - ? getDefaultMainLoopModel() - : parseUserSpecifiedModel(value) + if (!value) return undefined; + return value === NO_PREFERENCE ? getDefaultMainLoopModel() : parseUserSpecifiedModel(value); } -function EffortLevelIndicator({ - effort, -}: { - effort?: EffortLevel -}): React.ReactNode { - return ( - - {effortLevelToSymbol(effort ?? 'low')} - - ) +function EffortLevelIndicator({ effort }: { effort?: EffortLevel }): React.ReactNode { + return {effortLevelToSymbol(effort ?? 'low')}; } -function cycleEffortLevel( - current: EffortLevel, - direction: 'left' | 'right', - includeMax: boolean, -): EffortLevel { - const levels: EffortLevel[] = includeMax - ? ['low', 'medium', 'high', 'max'] - : ['low', 'medium', 'high'] +function cycleEffortLevel(current: EffortLevel, direction: 'left' | 'right', includeMax: boolean): EffortLevel { + const levels: EffortLevel[] = includeMax ? ['low', 'medium', 'high', 'max'] : ['low', 'medium', 'high']; // If the current level isn't in the cycle (e.g. 'max' after switching to a // non-Opus model), clamp to 'high'. - const idx = levels.indexOf(current) - const currentIndex = idx !== -1 ? idx : levels.indexOf('high') + const idx = levels.indexOf(current); + const currentIndex = idx !== -1 ? idx : levels.indexOf('high'); if (direction === 'right') { - return levels[(currentIndex + 1) % levels.length]! + return levels[(currentIndex + 1) % levels.length]!; } else { - return levels[(currentIndex - 1 + levels.length) % levels.length]! + return levels[(currentIndex - 1 + levels.length) % levels.length]!; } } function getDefaultEffortLevelForOption(value?: string): EffortLevel { - const resolved = resolveOptionModel(value) ?? getDefaultMainLoopModel() - const defaultValue = getDefaultEffortForModel(resolved) - return defaultValue !== undefined - ? convertEffortValueToLevel(defaultValue) - : 'high' + const resolved = resolveOptionModel(value) ?? getDefaultMainLoopModel(); + const defaultValue = getDefaultEffortForModel(resolved); + return defaultValue !== undefined ? convertEffortValueToLevel(defaultValue) : 'high'; } diff --git a/src/utils/logoV2Utils.ts b/src/utils/logoV2Utils.ts index 4357119d7..f7dc4c057 100644 --- a/src/utils/logoV2Utils.ts +++ b/src/utils/logoV2Utils.ts @@ -96,9 +96,9 @@ export function calculateOptimalLeftWidth( */ export function formatWelcomeMessage(username: string | null): string { if (!username || username.length > MAX_USERNAME_LENGTH) { - return 'Welcome back!' + return 'Welcome to Raincode' } - return `Welcome back ${username}!` + return `Welcome back to Raincode, ${username}!` } /** diff --git a/src/utils/model/__tests__/modelOptions.test.ts b/src/utils/model/__tests__/modelOptions.test.ts new file mode 100644 index 000000000..00b1755ac --- /dev/null +++ b/src/utils/model/__tests__/modelOptions.test.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mock } from 'bun:test' + +let mockedModelType: 'openai' | 'gemini' | undefined + +mock.module('../../settings/settings.js', () => ({ + getInitialSettings: () => + mockedModelType ? { modelType: mockedModelType } : {}, + getSettings_DEPRECATED: () => ({}), + getSettingsForSource: () => ({}), + updateSettingsForSource: () => {}, +})) + +const { getModelOptions } = await import('../modelOptions.js') + +describe('getModelOptions', () => { + const envKeys = [ + 'ANTHROPIC_API_KEY', + 'OPENAI_MODEL', + 'OPENAI_DEFAULT_SONNET_MODEL', + 'OPENAI_DEFAULT_OPUS_MODEL', + 'OPENAI_DEFAULT_HAIKU_MODEL', + 'GEMINI_MODEL', + 'GEMINI_DEFAULT_SONNET_MODEL', + 'GEMINI_DEFAULT_OPUS_MODEL', + 'GEMINI_DEFAULT_HAIKU_MODEL', + ] as const + const savedEnv: Record = {} + + beforeEach(() => { + mockedModelType = undefined + for (const key of envKeys) { + savedEnv[key] = process.env[key] + delete process.env[key] + } + process.env.ANTHROPIC_API_KEY = 'test-key' + }) + + afterEach(() => { + mockedModelType = undefined + for (const key of envKeys) { + if (savedEnv[key] !== undefined) { + process.env[key] = savedEnv[key] + } else { + delete process.env[key] + } + } + }) + + test('shows resolved OpenAI model names in labels', () => { + mockedModelType = 'openai' + process.env.OPENAI_DEFAULT_SONNET_MODEL = 'gpt-5.4' + process.env.OPENAI_DEFAULT_OPUS_MODEL = 'o3' + process.env.OPENAI_DEFAULT_HAIKU_MODEL = 'gpt-4o-mini' + + const options = getModelOptions(false) + + expect(options.some(option => option.label === 'gpt-5.4')).toBe(true) + expect(options.some(option => option.label === 'o3')).toBe(true) + expect(options.some(option => option.label === 'gpt-4o-mini')).toBe(true) + }) + + test('shows forced OpenAI override model in labels when OPENAI_MODEL is set', () => { + mockedModelType = 'openai' + process.env.OPENAI_MODEL = 'gpt-5.4' + + const options = getModelOptions(false) + + expect(options.some(option => option.label === 'gpt-5.4 (Sonnet)')).toBe( + true, + ) + expect(options.some(option => option.label === 'gpt-5.4 (Opus 4.1)')).toBe( + true, + ) + }) + + test('shows resolved Gemini model names in labels', () => { + mockedModelType = 'gemini' + process.env.GEMINI_DEFAULT_SONNET_MODEL = 'gemini-2.5-flash' + process.env.GEMINI_DEFAULT_OPUS_MODEL = 'gemini-2.5-pro' + process.env.GEMINI_DEFAULT_HAIKU_MODEL = 'gemini-2.5-flash-lite' + + const options = getModelOptions(false) + + expect(options.some(option => option.label === 'gemini-2.5-flash')).toBe( + true, + ) + expect(options.some(option => option.label === 'gemini-2.5-pro')).toBe(true) + expect( + options.some(option => option.label === 'gemini-2.5-flash-lite'), + ).toBe(true) + }) +}) diff --git a/src/utils/model/__tests__/providers.test.ts b/src/utils/model/__tests__/providers.test.ts index 0ed816f9e..42cfcf9af 100644 --- a/src/utils/model/__tests__/providers.test.ts +++ b/src/utils/model/__tests__/providers.test.ts @@ -1,173 +1,205 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { mock } from "bun:test"; +import { describe, expect, test, beforeEach, afterEach } from 'bun:test' +import { mock } from 'bun:test' -let mockedModelType: "gemini" | undefined; +let mockedModelType: 'gemini' | undefined -mock.module("../../settings/settings.js", () => ({ +mock.module('../../settings/settings.js', () => ({ getInitialSettings: () => mockedModelType ? { modelType: mockedModelType } : {}, -})); +})) -const { getAPIProvider, isFirstPartyAnthropicBaseUrl } = - await import("../providers"); +const { + getAPIProvider, + getAPIProviderDisplayName, + isFirstPartyAnthropicBaseUrl, +} = await import('../providers') -describe("getAPIProvider", () => { +describe('getAPIProvider', () => { const envKeys = [ - "CLAUDE_CODE_USE_GEMINI", - "CLAUDE_CODE_USE_BEDROCK", - "CLAUDE_CODE_USE_VERTEX", - "CLAUDE_CODE_USE_FOUNDRY", - "CLAUDE_CODE_USE_OPENAI", - ] as const; - const savedEnv: Record = {}; - + 'CLAUDE_CODE_USE_GEMINI', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_USE_OPENAI', + ] as const + const savedEnv: Record = {} beforeEach(() => { // Save and clear environment variables - mockedModelType = undefined; + mockedModelType = undefined for (const key of envKeys) { - savedEnv[key] = process.env[key]; - delete process.env[key]; + savedEnv[key] = process.env[key] + delete process.env[key] } - }); + }) afterEach(() => { // Restore environment variables - mockedModelType = undefined; + mockedModelType = undefined for (const key of envKeys) { if (savedEnv[key] !== undefined) { - process.env[key] = savedEnv[key]; + process.env[key] = savedEnv[key] } else { - delete process.env[key]; + delete process.env[key] } } - }); + }) test('returns "firstParty" by default', () => { - expect(getAPIProvider()).toBe("firstParty"); - }); + expect(getAPIProvider()).toBe('firstParty') + }) test('returns "gemini" when modelType is gemini', () => { - mockedModelType = "gemini"; - expect(getAPIProvider()).toBe("gemini"); - }); + mockedModelType = 'gemini' + expect(getAPIProvider()).toBe('gemini') + }) - test("modelType takes precedence over environment variables", () => { - mockedModelType = "gemini"; - process.env.CLAUDE_CODE_USE_BEDROCK = "1"; - expect(getAPIProvider()).toBe("gemini"); - }); + test('modelType takes precedence over environment variables', () => { + mockedModelType = 'gemini' + process.env.CLAUDE_CODE_USE_BEDROCK = '1' + expect(getAPIProvider()).toBe('gemini') + }) test('returns "gemini" when CLAUDE_CODE_USE_GEMINI is set', () => { - process.env.CLAUDE_CODE_USE_GEMINI = "1"; - expect(getAPIProvider()).toBe("gemini"); - }); + process.env.CLAUDE_CODE_USE_GEMINI = '1' + expect(getAPIProvider()).toBe('gemini') + }) test('returns "bedrock" when CLAUDE_CODE_USE_BEDROCK is set', () => { - process.env.CLAUDE_CODE_USE_BEDROCK = "1"; - expect(getAPIProvider()).toBe("bedrock"); - }); + process.env.CLAUDE_CODE_USE_BEDROCK = '1' + expect(getAPIProvider()).toBe('bedrock') + }) test('returns "vertex" when CLAUDE_CODE_USE_VERTEX is set', () => { - process.env.CLAUDE_CODE_USE_VERTEX = "1"; - expect(getAPIProvider()).toBe("vertex"); - }); + process.env.CLAUDE_CODE_USE_VERTEX = '1' + expect(getAPIProvider()).toBe('vertex') + }) test('returns "foundry" when CLAUDE_CODE_USE_FOUNDRY is set', () => { - process.env.CLAUDE_CODE_USE_FOUNDRY = "1"; - expect(getAPIProvider()).toBe("foundry"); - }); - - test("bedrock takes precedence over gemini", () => { - process.env.CLAUDE_CODE_USE_BEDROCK = "1"; - process.env.CLAUDE_CODE_USE_GEMINI = "1"; - expect(getAPIProvider()).toBe("bedrock"); - }); - - test("bedrock takes precedence over vertex", () => { - process.env.CLAUDE_CODE_USE_BEDROCK = "1"; - process.env.CLAUDE_CODE_USE_VERTEX = "1"; - expect(getAPIProvider()).toBe("bedrock"); - }); - - test("bedrock wins when all three env vars are set", () => { - process.env.CLAUDE_CODE_USE_BEDROCK = "1"; - process.env.CLAUDE_CODE_USE_VERTEX = "1"; - process.env.CLAUDE_CODE_USE_FOUNDRY = "1"; - expect(getAPIProvider()).toBe("bedrock"); - }); + process.env.CLAUDE_CODE_USE_FOUNDRY = '1' + expect(getAPIProvider()).toBe('foundry') + }) + + test('bedrock takes precedence over gemini', () => { + process.env.CLAUDE_CODE_USE_BEDROCK = '1' + process.env.CLAUDE_CODE_USE_GEMINI = '1' + expect(getAPIProvider()).toBe('bedrock') + }) + + test('bedrock takes precedence over vertex', () => { + process.env.CLAUDE_CODE_USE_BEDROCK = '1' + process.env.CLAUDE_CODE_USE_VERTEX = '1' + expect(getAPIProvider()).toBe('bedrock') + }) + + test('bedrock wins when all three env vars are set', () => { + process.env.CLAUDE_CODE_USE_BEDROCK = '1' + process.env.CLAUDE_CODE_USE_VERTEX = '1' + process.env.CLAUDE_CODE_USE_FOUNDRY = '1' + expect(getAPIProvider()).toBe('bedrock') + }) test('"true" is truthy', () => { - process.env.CLAUDE_CODE_USE_BEDROCK = "true"; - expect(getAPIProvider()).toBe("bedrock"); - }); + process.env.CLAUDE_CODE_USE_BEDROCK = 'true' + expect(getAPIProvider()).toBe('bedrock') + }) test('"0" is not truthy', () => { - process.env.CLAUDE_CODE_USE_BEDROCK = "0"; - expect(getAPIProvider()).toBe("firstParty"); - }); + process.env.CLAUDE_CODE_USE_BEDROCK = '0' + expect(getAPIProvider()).toBe('firstParty') + }) test('empty string is not truthy', () => { - process.env.CLAUDE_CODE_USE_BEDROCK = ""; - expect(getAPIProvider()).toBe("firstParty"); - }); -}); + process.env.CLAUDE_CODE_USE_BEDROCK = '' + expect(getAPIProvider()).toBe('firstParty') + }) +}) + +describe('getAPIProviderDisplayName', () => { + test('returns "Claude" for firstParty', () => { + expect(getAPIProviderDisplayName('firstParty')).toBe('Claude') + }) + + test('returns "OpenAI-compatible" for openai', () => { + expect(getAPIProviderDisplayName('openai')).toBe('OpenAI-compatible') + }) + + test('returns "Gemini" for gemini', () => { + expect(getAPIProviderDisplayName('gemini')).toBe('Gemini') + }) + + test('returns "Grok" for grok', () => { + expect(getAPIProviderDisplayName('grok')).toBe('Grok') + }) + + test('returns "AWS Bedrock" for bedrock', () => { + expect(getAPIProviderDisplayName('bedrock')).toBe('AWS Bedrock') + }) + + test('returns "Google Vertex AI" for vertex', () => { + expect(getAPIProviderDisplayName('vertex')).toBe('Google Vertex AI') + }) + + test('returns "Azure AI Foundry" for foundry', () => { + expect(getAPIProviderDisplayName('foundry')).toBe('Azure AI Foundry') + }) +}) -describe("isFirstPartyAnthropicBaseUrl", () => { - const originalBaseUrl = process.env.ANTHROPIC_BASE_URL; - const originalUserType = process.env.USER_TYPE; +describe('isFirstPartyAnthropicBaseUrl', () => { + const originalBaseUrl = process.env.ANTHROPIC_BASE_URL + const originalUserType = process.env.USER_TYPE afterEach(() => { if (originalBaseUrl !== undefined) { - process.env.ANTHROPIC_BASE_URL = originalBaseUrl; + process.env.ANTHROPIC_BASE_URL = originalBaseUrl } else { - delete process.env.ANTHROPIC_BASE_URL; + delete process.env.ANTHROPIC_BASE_URL } if (originalUserType !== undefined) { - process.env.USER_TYPE = originalUserType; + process.env.USER_TYPE = originalUserType } else { - delete process.env.USER_TYPE; + delete process.env.USER_TYPE } - }); - - test("returns true when ANTHROPIC_BASE_URL is not set", () => { - delete process.env.ANTHROPIC_BASE_URL; - expect(isFirstPartyAnthropicBaseUrl()).toBe(true); - }); - - test("returns true for api.anthropic.com", () => { - process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com"; - expect(isFirstPartyAnthropicBaseUrl()).toBe(true); - }); - - test("returns false for custom URL", () => { - process.env.ANTHROPIC_BASE_URL = "https://my-proxy.com"; - expect(isFirstPartyAnthropicBaseUrl()).toBe(false); - }); - - test("returns false for invalid URL", () => { - process.env.ANTHROPIC_BASE_URL = "not-a-url"; - expect(isFirstPartyAnthropicBaseUrl()).toBe(false); - }); - - test("returns true for staging URL when USER_TYPE is ant", () => { - process.env.ANTHROPIC_BASE_URL = "https://api-staging.anthropic.com"; - process.env.USER_TYPE = "ant"; - expect(isFirstPartyAnthropicBaseUrl()).toBe(true); - }); - - test("returns true for URL with path", () => { - process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com/v1"; - expect(isFirstPartyAnthropicBaseUrl()).toBe(true); - }); - - test("returns true for trailing slash", () => { - process.env.ANTHROPIC_BASE_URL = "https://api.anthropic.com/"; - expect(isFirstPartyAnthropicBaseUrl()).toBe(true); - }); - - test("returns false for subdomain attack", () => { - process.env.ANTHROPIC_BASE_URL = "https://evil-api.anthropic.com"; - expect(isFirstPartyAnthropicBaseUrl()).toBe(false); - }); -}); + }) + + test('returns true when ANTHROPIC_BASE_URL is not set', () => { + delete process.env.ANTHROPIC_BASE_URL + expect(isFirstPartyAnthropicBaseUrl()).toBe(true) + }) + + test('returns true for api.anthropic.com', () => { + process.env.ANTHROPIC_BASE_URL = 'https://api.anthropic.com' + expect(isFirstPartyAnthropicBaseUrl()).toBe(true) + }) + + test('returns false for custom URL', () => { + process.env.ANTHROPIC_BASE_URL = 'https://my-proxy.com' + expect(isFirstPartyAnthropicBaseUrl()).toBe(false) + }) + + test('returns false for invalid URL', () => { + process.env.ANTHROPIC_BASE_URL = 'not-a-url' + expect(isFirstPartyAnthropicBaseUrl()).toBe(false) + }) + + test('returns true for staging URL when USER_TYPE is ant', () => { + process.env.ANTHROPIC_BASE_URL = 'https://api-staging.anthropic.com' + process.env.USER_TYPE = 'ant' + expect(isFirstPartyAnthropicBaseUrl()).toBe(true) + }) + + test('returns true for URL with path', () => { + process.env.ANTHROPIC_BASE_URL = 'https://api.anthropic.com/v1' + expect(isFirstPartyAnthropicBaseUrl()).toBe(true) + }) + + test('returns true for trailing slash', () => { + process.env.ANTHROPIC_BASE_URL = 'https://api.anthropic.com/' + expect(isFirstPartyAnthropicBaseUrl()).toBe(true) + }) + + test('returns false for subdomain attack', () => { + process.env.ANTHROPIC_BASE_URL = 'https://evil-api.anthropic.com' + expect(isFirstPartyAnthropicBaseUrl()).toBe(false) + }) +}) diff --git a/src/utils/model/modelOptions.ts b/src/utils/model/modelOptions.ts index 6d84a187f..bf2242655 100644 --- a/src/utils/model/modelOptions.ts +++ b/src/utils/model/modelOptions.ts @@ -33,6 +33,9 @@ import { } from './model.js' import { has1mContext } from '../context.js' import { getGlobalConfig } from '../config.js' +import { resolveOpenAIModel } from '../../services/api/openai/modelMapping.js' +import { resolveGeminiModel } from '../../services/api/gemini/modelMapping.js' +import { resolveGrokModel } from '../../services/api/grok/modelMapping.js' // @[MODEL LAUNCH]: Update all the available and default model option strings below. @@ -43,6 +46,36 @@ export type ModelOption = { descriptionForModel?: string } +function resolveProviderDisplayModel(model: string): string { + const provider = getAPIProvider() + try { + switch (provider) { + case 'openai': + return resolveOpenAIModel(model) + case 'gemini': + return resolveGeminiModel(model) + case 'grok': + return resolveGrokModel(model) + default: + return model + } + } catch { + return model + } +} + +function formatThirdPartyModelLabel( + fallbackLabel: string, + model: string, + aliasSuffix?: string, +): string { + if (getAPIProvider() === 'firstParty') { + return fallbackLabel + } + const resolved = resolveProviderDisplayModel(model) + return aliasSuffix ? `${resolved} (${aliasSuffix})` : resolved +} + export function getDefaultOptionForUser(fastMode = false): ModelOption { if (process.env.USER_TYPE === 'ant') { const currentModel = renderDefaultModelSetting( @@ -82,8 +115,8 @@ function getCustomSonnetOption(): ModelOption | undefined { provider === 'openai' ? process.env.OPENAI_DEFAULT_SONNET_MODEL : provider === 'gemini' - ? process.env.GEMINI_DEFAULT_SONNET_MODEL - : process.env.ANTHROPIC_DEFAULT_SONNET_MODEL + ? process.env.GEMINI_DEFAULT_SONNET_MODEL + : process.env.ANTHROPIC_DEFAULT_SONNET_MODEL // When a 3P user has a custom sonnet model string, show it directly if (is3P && customSonnetModel) { const is1m = has1mContext(customSonnetModel) @@ -92,14 +125,14 @@ function getCustomSonnetOption(): ModelOption | undefined { provider === 'openai' ? process.env.OPENAI_DEFAULT_SONNET_MODEL_NAME : provider === 'gemini' - ? process.env.GEMINI_DEFAULT_SONNET_MODEL_NAME - : process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME + ? process.env.GEMINI_DEFAULT_SONNET_MODEL_NAME + : process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_NAME const descEnv = provider === 'openai' ? process.env.OPENAI_DEFAULT_SONNET_MODEL_DESCRIPTION : provider === 'gemini' - ? process.env.GEMINI_DEFAULT_SONNET_MODEL_DESCRIPTION - : process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION + ? process.env.GEMINI_DEFAULT_SONNET_MODEL_DESCRIPTION + : process.env.ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION return { value: 'sonnet', label: nameEnv ?? customSonnetModel, @@ -116,7 +149,11 @@ function getSonnet46Option(): ModelOption { const is3P = getAPIProvider() !== 'firstParty' return { value: is3P ? getModelStrings().sonnet46 : 'sonnet', - label: 'Sonnet', + label: formatThirdPartyModelLabel( + 'Sonnet', + getModelStrings().sonnet46, + is3P ? 'Sonnet' : undefined, + ), description: `Sonnet 4.6 · Best for everyday tasks${is3P ? '' : ` · ${formatModelPricing(COST_TIER_3_15)}`}`, descriptionForModel: 'Sonnet 4.6 - best for everyday tasks. Generally recommended for most coding tasks', @@ -131,8 +168,8 @@ function getCustomOpusOption(): ModelOption | undefined { provider === 'openai' ? process.env.OPENAI_DEFAULT_OPUS_MODEL : provider === 'gemini' - ? process.env.GEMINI_DEFAULT_OPUS_MODEL - : process.env.ANTHROPIC_DEFAULT_OPUS_MODEL + ? process.env.GEMINI_DEFAULT_OPUS_MODEL + : process.env.ANTHROPIC_DEFAULT_OPUS_MODEL // When a 3P user has a custom opus model string, show it directly if (is3P && customOpusModel) { const is1m = has1mContext(customOpusModel) @@ -141,14 +178,14 @@ function getCustomOpusOption(): ModelOption | undefined { provider === 'openai' ? process.env.OPENAI_DEFAULT_OPUS_MODEL_NAME : provider === 'gemini' - ? process.env.GEMINI_DEFAULT_OPUS_MODEL_NAME - : process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME + ? process.env.GEMINI_DEFAULT_OPUS_MODEL_NAME + : process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_NAME const descEnv = provider === 'openai' ? process.env.OPENAI_DEFAULT_OPUS_MODEL_DESCRIPTION : provider === 'gemini' - ? process.env.GEMINI_DEFAULT_OPUS_MODEL_DESCRIPTION - : process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION + ? process.env.GEMINI_DEFAULT_OPUS_MODEL_DESCRIPTION + : process.env.ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION return { value: 'opus', label: nameEnv ?? customOpusModel, @@ -159,9 +196,14 @@ function getCustomOpusOption(): ModelOption | undefined { } function getOpus41Option(): ModelOption { + const is3P = getAPIProvider() !== 'firstParty' return { value: 'opus', - label: 'Opus 4.1', + label: formatThirdPartyModelLabel( + 'Opus 4.1', + getModelStrings().opus41, + is3P ? 'Opus 4.1' : undefined, + ), description: `Opus 4.1 · Legacy`, descriptionForModel: 'Opus 4.1 - legacy version', } @@ -171,7 +213,11 @@ function getOpus46Option(fastMode = false): ModelOption { const is3P = getAPIProvider() !== 'firstParty' return { value: is3P ? getModelStrings().opus46 : 'opus', - label: 'Opus', + label: formatThirdPartyModelLabel( + 'Opus', + getModelStrings().opus46, + is3P ? 'Opus' : undefined, + ), description: `Opus 4.6 · Most capable for complex work${getOpus46PricingSuffix(fastMode)}`, descriptionForModel: 'Opus 4.6 - most capable for complex work', } @@ -179,9 +225,14 @@ function getOpus46Option(fastMode = false): ModelOption { export function getSonnet46_1MOption(): ModelOption { const is3P = getAPIProvider() !== 'firstParty' + const model = getModelStrings().sonnet46 + '[1m]' return { - value: is3P ? getModelStrings().sonnet46 + '[1m]' : 'sonnet[1m]', - label: 'Sonnet (1M context)', + value: is3P ? model : 'sonnet[1m]', + label: formatThirdPartyModelLabel( + 'Sonnet (1M context)', + model, + is3P ? 'Sonnet 1M' : undefined, + ), description: `Sonnet 4.6 for long sessions${is3P ? '' : ` · ${formatModelPricing(COST_TIER_3_15)}`}`, descriptionForModel: 'Sonnet 4.6 with 1M context window - for long sessions with large codebases', @@ -190,9 +241,14 @@ export function getSonnet46_1MOption(): ModelOption { export function getOpus46_1MOption(fastMode = false): ModelOption { const is3P = getAPIProvider() !== 'firstParty' + const model = getModelStrings().opus46 + '[1m]' return { - value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]', - label: 'Opus (1M context)', + value: is3P ? model : 'opus[1m]', + label: formatThirdPartyModelLabel( + 'Opus (1M context)', + model, + is3P ? 'Opus 1M' : undefined, + ), description: `Opus 4.6 for long sessions${getOpus46PricingSuffix(fastMode)}`, descriptionForModel: 'Opus 4.6 with 1M context window - for long sessions with large codebases', @@ -207,8 +263,8 @@ function getCustomHaikuOption(): ModelOption | undefined { provider === 'openai' ? process.env.OPENAI_DEFAULT_HAIKU_MODEL : provider === 'gemini' - ? process.env.GEMINI_DEFAULT_HAIKU_MODEL - : process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL + ? process.env.GEMINI_DEFAULT_HAIKU_MODEL + : process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL // When a 3P user has a custom haiku model string, show it directly if (is3P && customHaikuModel) { // Use appropriate NAME/DESCRIPTION env vars based on provider @@ -216,14 +272,14 @@ function getCustomHaikuOption(): ModelOption | undefined { provider === 'openai' ? process.env.OPENAI_DEFAULT_HAIKU_MODEL_NAME : provider === 'gemini' - ? process.env.GEMINI_DEFAULT_HAIKU_MODEL_NAME - : process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME + ? process.env.GEMINI_DEFAULT_HAIKU_MODEL_NAME + : process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME const descEnv = provider === 'openai' ? process.env.OPENAI_DEFAULT_HAIKU_MODEL_DESCRIPTION : provider === 'gemini' - ? process.env.GEMINI_DEFAULT_HAIKU_MODEL_DESCRIPTION - : process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION + ? process.env.GEMINI_DEFAULT_HAIKU_MODEL_DESCRIPTION + : process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION return { value: 'haiku', label: nameEnv ?? customHaikuModel, @@ -237,7 +293,11 @@ function getHaiku45Option(): ModelOption { const is3P = getAPIProvider() !== 'firstParty' return { value: 'haiku', - label: 'Haiku', + label: formatThirdPartyModelLabel( + 'Haiku', + getModelStrings().haiku45, + is3P ? 'Haiku' : undefined, + ), description: `Haiku 4.5 · Fastest for quick answers${is3P ? '' : ` · ${formatModelPricing(COST_HAIKU_45)}`}`, descriptionForModel: 'Haiku 4.5 - fastest for quick answers. Lower cost but less capable than Sonnet 4.6.', @@ -248,7 +308,11 @@ function getHaiku35Option(): ModelOption { const is3P = getAPIProvider() !== 'firstParty' return { value: 'haiku', - label: 'Haiku', + label: formatThirdPartyModelLabel( + 'Haiku', + getModelStrings().haiku35, + is3P ? 'Haiku' : undefined, + ), description: `Haiku 3.5 for simple tasks${is3P ? '' : ` · ${formatModelPricing(COST_HAIKU_35)}`}`, descriptionForModel: 'Haiku 3.5 - faster and lower cost, but less capable than Sonnet. Use for simple tasks.', @@ -292,9 +356,14 @@ export function getMaxOpus46_1MOption(fastMode = false): ModelOption { function getMergedOpus1MOption(fastMode = false): ModelOption { const is3P = getAPIProvider() !== 'firstParty' + const model = getModelStrings().opus46 + '[1m]' return { - value: is3P ? getModelStrings().opus46 + '[1m]' : 'opus[1m]', - label: 'Opus (1M context)', + value: is3P ? model : 'opus[1m]', + label: formatThirdPartyModelLabel( + 'Opus (1M context)', + model, + is3P ? 'Opus 1M' : undefined, + ), description: `Opus 4.6 with 1M context · Most capable for complex work${!is3P && fastMode ? getOpus46PricingSuffix(fastMode) : ''}`, descriptionForModel: 'Opus 4.6 with 1M context - most capable for complex work', diff --git a/src/utils/model/providers.ts b/src/utils/model/providers.ts index 823384f2d..830428e14 100644 --- a/src/utils/model/providers.ts +++ b/src/utils/model/providers.ts @@ -11,6 +11,27 @@ export type APIProvider = | 'gemini' | 'grok' +export function getAPIProviderDisplayName( + provider: APIProvider = getAPIProvider(), +): string { + switch (provider) { + case 'firstParty': + return 'Claude' + case 'openai': + return 'OpenAI-compatible' + case 'gemini': + return 'Gemini' + case 'grok': + return 'Grok' + case 'bedrock': + return 'AWS Bedrock' + case 'vertex': + return 'Google Vertex AI' + case 'foundry': + return 'Azure AI Foundry' + } +} + export function getAPIProvider(): APIProvider { const modelType = getInitialSettings().modelType if (modelType === 'openai') return 'openai' From 197bdda6c70222a63093d116c17abc5dc3c43d3f Mon Sep 17 00:00:00 2001 From: rainhole Date: Wed, 8 Apr 2026 13:57:39 +0800 Subject: [PATCH 3/3] Fix OpenAI side-query routing and third-party telemetry gating --- .../analytics/__tests__/config.test.ts | 47 +++ src/services/analytics/config.ts | 8 +- src/services/tokenEstimation.ts | 8 +- src/utils/__tests__/sideQuery.test.ts | 244 ++++++++++++ src/utils/fastMode.ts | 5 +- src/utils/sideQuery.ts | 367 ++++++++++++++---- 6 files changed, 597 insertions(+), 82 deletions(-) create mode 100644 src/services/analytics/__tests__/config.test.ts create mode 100644 src/utils/__tests__/sideQuery.test.ts diff --git a/src/services/analytics/__tests__/config.test.ts b/src/services/analytics/__tests__/config.test.ts new file mode 100644 index 000000000..ba24bf593 --- /dev/null +++ b/src/services/analytics/__tests__/config.test.ts @@ -0,0 +1,47 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' + +let provider: 'firstParty' | 'openai' = 'firstParty' +let telemetryDisabled = false + +mock.module('../../../utils/model/providers.js', () => ({ + getAPIProvider: () => provider, +})) + +mock.module('../../../utils/privacyLevel.js', () => ({ + isTelemetryDisabled: () => telemetryDisabled, +})) + +const { isAnalyticsDisabled } = await import('../config.js') + +describe('isAnalyticsDisabled', () => { + const originalNodeEnv = process.env.NODE_ENV + + beforeEach(() => { + provider = 'firstParty' + telemetryDisabled = false + delete process.env.NODE_ENV + }) + + afterEach(() => { + if (originalNodeEnv !== undefined) { + process.env.NODE_ENV = originalNodeEnv + } else { + delete process.env.NODE_ENV + } + }) + + test('disables analytics for OpenAI-compatible providers', () => { + provider = 'openai' + expect(isAnalyticsDisabled()).toBe(true) + }) + + test('keeps analytics enabled for first-party provider when privacy allows it', () => { + provider = 'firstParty' + expect(isAnalyticsDisabled()).toBe(false) + }) + + test('disables analytics when telemetry privacy is enabled', () => { + telemetryDisabled = true + expect(isAnalyticsDisabled()).toBe(true) + }) +}) diff --git a/src/services/analytics/config.ts b/src/services/analytics/config.ts index 9e80601b0..e749d26aa 100644 --- a/src/services/analytics/config.ts +++ b/src/services/analytics/config.ts @@ -5,7 +5,7 @@ * across all analytics systems (Datadog, 1P) */ -import { isEnvTruthy } from '../../utils/envUtils.js' +import { getAPIProvider } from '../../utils/model/providers.js' import { isTelemetryDisabled } from '../../utils/privacyLevel.js' /** @@ -13,15 +13,13 @@ import { isTelemetryDisabled } from '../../utils/privacyLevel.js' * * Analytics is disabled in the following cases: * - Test environment (NODE_ENV === 'test') - * - Third-party cloud providers (Bedrock/Vertex) + * - Third-party model providers (OpenAI/Gemini/Grok/Bedrock/Vertex/Foundry) * - Privacy level is no-telemetry or essential-traffic */ export function isAnalyticsDisabled(): boolean { return ( process.env.NODE_ENV === 'test' || - isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) || - isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || - isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) || + getAPIProvider() !== 'firstParty' || isTelemetryDisabled() ) } diff --git a/src/services/tokenEstimation.ts b/src/services/tokenEstimation.ts index c59d53a3a..db60aeb86 100644 --- a/src/services/tokenEstimation.ts +++ b/src/services/tokenEstimation.ts @@ -144,7 +144,11 @@ export async function countMessagesTokensWithAPI( return withTokenCountVCR(messages, tools, async () => { try { const provider = getAPIProvider() - if (provider === 'gemini') { + if ( + provider === 'gemini' || + provider === 'openai' || + provider === 'grok' + ) { return roughTokenCountEstimationForAPIRequest(messages, tools) } @@ -258,7 +262,7 @@ export async function countTokensViaHaikuFallback( tools: Anthropic.Beta.Messages.BetaToolUnion[], ): Promise { const provider = getAPIProvider() - if (provider === 'gemini') { + if (provider === 'gemini' || provider === 'openai' || provider === 'grok') { return roughTokenCountEstimationForAPIRequest(messages, tools) } diff --git a/src/utils/__tests__/sideQuery.test.ts b/src/utils/__tests__/sideQuery.test.ts new file mode 100644 index 000000000..da9c75365 --- /dev/null +++ b/src/utils/__tests__/sideQuery.test.ts @@ -0,0 +1,244 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' + +const getLastApiCompletionTimestamp = mock(() => null) +const setLastApiCompletionTimestamp = mock(() => {}) +const logEvent = mock(() => {}) +const anthropicMessagesToOpenAI = mock(() => [ + { role: 'system', content: 'converted-system' }, +]) +const anthropicToolsToOpenAI = mock(() => [ + { + type: 'function', + function: { + name: 'explain_command', + parameters: { type: 'object' }, + }, + }, +]) +const anthropicToolChoiceToOpenAI = mock(() => ({ + type: 'function', + function: { name: 'explain_command' }, +})) +const openAICreate = mock(async () => ({ + id: 'resp_123', + model: 'gpt-5.4', + choices: [ + { + finish_reason: 'tool_calls', + message: { + content: 'Structured explanation ready', + tool_calls: [ + { + id: 'call_123', + type: 'function', + function: { + name: 'explain_command', + arguments: + '{"riskLevel":"LOW","explanation":"safe","reasoning":"needed","risk":"none"}', + }, + }, + ], + }, + }, + ], + usage: { + prompt_tokens: 11, + completion_tokens: 7, + }, +})) +const getOpenAIClient = mock(() => ({ + chat: { + completions: { + create: openAICreate, + }, + }, +})) +const getAnthropicClient = mock(async () => { + throw new Error('Anthropic client should not be used for OpenAI sideQuery') +}) + +mock.module('../../bootstrap/state.js', () => ({ + getLastApiCompletionTimestamp, + setLastApiCompletionTimestamp, +})) + +mock.module('../../services/analytics/index.js', () => ({ + logEvent, +})) + +mock.module('../../constants/betas.js', () => ({ + STRUCTURED_OUTPUTS_BETA_HEADER: 'structured-outputs', +})) + +mock.module('../../constants/system.js', () => ({ + getAttributionHeader: () => '', + getCLISyspromptPrefix: () => '', +})) + +mock.module('../../services/api/claude.js', () => ({ + getAPIMetadata: () => ({}), +})) + +mock.module('../settings/settings.js', () => ({ + getInitialSettings: () => ({}), +})) + +mock.module('../../services/api/client.js', () => ({ + getAnthropicClient, +})) + +mock.module('../../services/api/openai/client.js', () => ({ + getOpenAIClient, +})) + +mock.module('../../services/api/openai/modelMapping.js', () => ({ + resolveOpenAIModel: () => 'gpt-5.4', +})) + +mock.module('../../services/api/openai/convertMessages.js', () => ({ + anthropicMessagesToOpenAI, +})) + +mock.module('../../services/api/openai/convertTools.js', () => ({ + anthropicToolsToOpenAI, + anthropicToolChoiceToOpenAI, +})) + +mock.module('../messages.js', () => ({ + createAssistantMessage: ({ content }: { content: unknown }) => ({ + type: 'assistant', + message: { content }, + }), + createUserMessage: ({ content }: { content: unknown }) => ({ + type: 'user', + message: { content }, + }), +})) + +mock.module('../../services/api/grok/client.js', () => ({ + getGrokClient: () => { + throw new Error('Grok client should not be used in this test') + }, +})) + +mock.module('../../services/api/grok/modelMapping.js', () => ({ + resolveGrokModel: () => 'grok-1', +})) + +mock.module('../betas.js', () => ({ + getModelBetas: () => [], + modelSupportsStructuredOutputs: () => true, +})) + +mock.module('../fingerprint.js', () => ({ + computeFingerprint: () => 'fingerprint', +})) + +mock.module('../json.js', () => ({ + safeParseJSON: (value: string) => JSON.parse(value), +})) + +mock.module('../model/model.js', () => ({ + normalizeModelStringForAPI: (model: string) => model, +})) + +const { sideQuery } = await import('../sideQuery.js') + +describe('sideQuery', () => { + const originalOpenAIFlag = process.env.CLAUDE_CODE_USE_OPENAI + + beforeEach(() => { + process.env.CLAUDE_CODE_USE_OPENAI = '1' + getLastApiCompletionTimestamp.mockClear() + setLastApiCompletionTimestamp.mockClear() + logEvent.mockClear() + anthropicMessagesToOpenAI.mockClear() + anthropicToolsToOpenAI.mockClear() + anthropicToolChoiceToOpenAI.mockClear() + openAICreate.mockClear() + getOpenAIClient.mockClear() + getAnthropicClient.mockClear() + }) + + afterEach(() => { + if (originalOpenAIFlag !== undefined) { + process.env.CLAUDE_CODE_USE_OPENAI = originalOpenAIFlag + } else { + delete process.env.CLAUDE_CODE_USE_OPENAI + } + }) + + test('routes OpenAI side queries through the OpenAI-compatible client', async () => { + const response = await sideQuery({ + model: 'claude-sonnet-4-5', + system: 'Explain the command safely', + messages: [{ role: 'user', content: 'Explain rm -rf build/' }], + tools: [ + { + name: 'explain_command', + description: 'Explain a command', + input_schema: { type: 'object' }, + }, + ] as any, + tool_choice: { type: 'tool', name: 'explain_command' } as any, + output_format: { + type: 'json_schema', + schema: { + type: 'object', + properties: { + riskLevel: { type: 'string' }, + }, + }, + }, + max_tokens: 256, + stop_sequences: [''], + querySource: 'permission_explainer', + }) + + expect(getOpenAIClient).toHaveBeenCalledTimes(1) + expect(getAnthropicClient).not.toHaveBeenCalled() + + expect(anthropicMessagesToOpenAI).toHaveBeenCalledTimes(1) + const [, systemPrompt] = anthropicMessagesToOpenAI.mock.calls[0]! + expect([...systemPrompt]).toEqual(['Explain the command safely']) + + expect(openAICreate).toHaveBeenCalledTimes(1) + const [params] = openAICreate.mock.calls[0]! + expect(params.model).toBe('gpt-5.4') + expect(params.max_completion_tokens).toBe(256) + expect(params.stop).toEqual(['']) + expect(params.response_format).toEqual({ + type: 'json_schema', + json_schema: { + name: 'side_query', + schema: { + type: 'object', + properties: { + riskLevel: { type: 'string' }, + }, + }, + strict: true, + }, + }) + + expect(response.id).toBe('resp_123') + expect(response.model).toBe('gpt-5.4') + expect(response.stop_reason).toBe('tool_use') + expect(response.usage.input_tokens).toBe(11) + expect(response.usage.output_tokens).toBe(7) + expect(response.content).toEqual([ + { type: 'text', text: 'Structured explanation ready' }, + { + type: 'tool_use', + id: 'call_123', + name: 'explain_command', + input: { + riskLevel: 'LOW', + explanation: 'safe', + reasoning: 'needed', + risk: 'none', + }, + }, + ]) + }) +}) diff --git a/src/utils/fastMode.ts b/src/utils/fastMode.ts index 98de3ee67..b78c64890 100644 --- a/src/utils/fastMode.ts +++ b/src/utils/fastMode.ts @@ -109,9 +109,10 @@ export function getFastModeUnavailableReason(): string | null { } } - // Only available for 1P (not Bedrock/Vertex/Foundry) + // Only available for Anthropic first-party sessions. if (getAPIProvider() !== 'firstParty') { - const reason = 'Fast mode is not available on Bedrock, Vertex, or Foundry' + const reason = + 'Fast mode is not available on OpenAI-compatible, Gemini, Grok, Bedrock, Vertex, or Foundry providers' logForDebugging(`Fast mode unavailable: ${reason}`) return reason } diff --git a/src/utils/sideQuery.ts b/src/utils/sideQuery.ts index 4e6d4d731..a51b026e6 100644 --- a/src/utils/sideQuery.ts +++ b/src/utils/sideQuery.ts @@ -1,5 +1,6 @@ import type Anthropic from '@anthropic-ai/sdk' import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages.js' +import { randomUUID } from 'crypto' import { getLastApiCompletionTimestamp, setLastApiCompletionTimestamp, @@ -14,9 +15,22 @@ import { logEvent } from '../services/analytics/index.js' import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../services/analytics/metadata.js' import { getAPIMetadata } from '../services/api/claude.js' import { getAnthropicClient } from '../services/api/client.js' +import { getGrokClient } from '../services/api/grok/client.js' +import { resolveGrokModel } from '../services/api/grok/modelMapping.js' +import { getOpenAIClient } from '../services/api/openai/client.js' +import { anthropicMessagesToOpenAI } from '../services/api/openai/convertMessages.js' +import { + anthropicToolChoiceToOpenAI, + anthropicToolsToOpenAI, +} from '../services/api/openai/convertTools.js' +import { resolveOpenAIModel } from '../services/api/openai/modelMapping.js' import { getModelBetas, modelSupportsStructuredOutputs } from './betas.js' import { computeFingerprint } from './fingerprint.js' +import { safeParseJSON } from './json.js' +import { createAssistantMessage, createUserMessage } from './messages.js' import { normalizeModelStringForAPI } from './model/model.js' +import { getAPIProvider } from './model/providers.js' +import { asSystemPrompt } from './systemPromptType.js' type MessageParam = Anthropic.MessageParam type TextBlockParam = Anthropic.TextBlockParam @@ -78,6 +92,200 @@ function extractFirstUserMessageText(messages: MessageParam[]): string { return textBlock?.type === 'text' ? textBlock.text : '' } +function toSystemPrompt(system?: string | TextBlockParam[]) { + return asSystemPrompt( + (Array.isArray(system) + ? system.map(block => block.text) + : system + ? [system] + : [] + ).filter(Boolean), + ) +} + +function toInternalMessages(messages: MessageParam[]) { + return messages.flatMap(message => { + if (message.role === 'assistant') { + return [ + createAssistantMessage({ + content: + typeof message.content === 'string' + ? message.content + : (message.content as BetaMessage['content']), + }), + ] + } + + if (message.role === 'user') { + return [ + createUserMessage({ + content: + typeof message.content === 'string' + ? message.content + : message.content, + }), + ] + } + + return [] + }) +} + +function extractOpenAIResponseText(content: unknown): string { + if (typeof content === 'string') { + return content + } + + if (!Array.isArray(content)) { + return '' + } + + return content + .map(part => { + if (!part || typeof part !== 'object') { + return '' + } + + if ('text' in part && typeof part.text === 'string') { + return part.text + } + + if ('refusal' in part && typeof part.refusal === 'string') { + return part.refusal + } + + return '' + }) + .filter(Boolean) + .join('\n') +} + +function normalizeOpenAIToolInput(input: string): string | unknown { + const parsed = safeParseJSON(input) + return parsed === null && input.length > 0 ? input : (parsed ?? {}) +} + +function mapOpenAIFinishReason( + reason: string | null | undefined, + hasToolCalls: boolean, +): BetaMessage['stop_reason'] { + if (hasToolCalls) { + return 'tool_use' + } + + switch (reason) { + case 'length': + return 'max_tokens' + case 'tool_calls': + return 'tool_use' + case 'stop': + case 'content_filter': + default: + return 'end_turn' + } +} + +async function sideQueryViaOpenAICompatibleProvider( + opts: SideQueryOptions, + provider: 'grok' | 'openai', +): Promise { + const internalMessages = toInternalMessages(opts.messages) + const systemPrompt = toSystemPrompt(opts.system) + const openaiMessages = anthropicMessagesToOpenAI( + internalMessages, + systemPrompt, + ) + const openaiTools = opts.tools + ? anthropicToolsToOpenAI(opts.tools as BetaToolUnion[]) + : undefined + const openaiToolChoice = anthropicToolChoiceToOpenAI(opts.tool_choice) + const resolvedModel = + provider === 'grok' + ? resolveGrokModel(opts.model) + : resolveOpenAIModel(opts.model) + + const client = + provider === 'grok' + ? getGrokClient({ + maxRetries: opts.maxRetries ?? 2, + source: opts.querySource, + }) + : getOpenAIClient({ + maxRetries: opts.maxRetries ?? 2, + source: opts.querySource, + }) + + const responseFormat = opts.output_format + ? { + type: 'json_schema' as const, + json_schema: { + name: 'side_query', + schema: opts.output_format.schema, + strict: true, + }, + } + : undefined + + const response = await client.chat.completions.create( + { + model: resolvedModel, + messages: openaiMessages, + ...(openaiTools && openaiTools.length > 0 && { tools: openaiTools }), + ...(openaiToolChoice && { tool_choice: openaiToolChoice }), + ...(responseFormat && { response_format: responseFormat }), + ...(opts.max_tokens !== undefined && { + max_completion_tokens: opts.max_tokens, + }), + ...(opts.temperature !== undefined && { temperature: opts.temperature }), + ...(opts.stop_sequences && { stop: opts.stop_sequences }), + }, + { + signal: opts.signal, + }, + ) + + const choice = response.choices[0] + const toolCalls = choice?.message.tool_calls ?? [] + const text = extractOpenAIResponseText(choice?.message.content) + const content: BetaMessage['content'] = [] + + if (text) { + content.push({ type: 'text', text }) + } + + for (const toolCall of toolCalls) { + if (toolCall.type !== 'function') { + continue + } + + content.push({ + type: 'tool_use', + id: toolCall.id ?? randomUUID(), + name: toolCall.function.name, + input: normalizeOpenAIToolInput(toolCall.function.arguments), + }) + } + + return { + id: response.id ?? randomUUID(), + type: 'message', + role: 'assistant', + model: response.model || resolvedModel, + content, + stop_reason: mapOpenAIFinishReason( + choice?.finish_reason, + toolCalls.length > 0, + ), + stop_sequence: null, + usage: { + input_tokens: response.usage?.prompt_tokens ?? 0, + output_tokens: response.usage?.completion_tokens ?? 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + } as BetaMessage +} + /** * Lightweight API wrapper for "side queries" outside the main conversation loop. * @@ -121,81 +329,94 @@ export async function sideQuery(opts: SideQueryOptions): Promise { stop_sequences, } = opts - const client = await getAnthropicClient({ - maxRetries, - model, - source: 'side_query', - }) - const betas = [...getModelBetas(model)] - // Add structured-outputs beta if using output_format and provider supports it - if ( - output_format && - modelSupportsStructuredOutputs(model) && - !betas.includes(STRUCTURED_OUTPUTS_BETA_HEADER) - ) { - betas.push(STRUCTURED_OUTPUTS_BETA_HEADER) - } - - // Extract first user message text for fingerprint - const messageText = extractFirstUserMessageText(messages) - - // Compute fingerprint for OAuth attribution - const fingerprint = computeFingerprint(messageText, MACRO.VERSION) - const attributionHeader = getAttributionHeader(fingerprint) - - // Build system as array to keep attribution header in its own block - // (prevents server-side parsing from including system content in cc_entrypoint) - const systemBlocks: TextBlockParam[] = [ - attributionHeader ? { type: 'text', text: attributionHeader } : null, - // Skip CLI system prompt prefix for internal classifiers that provide their own prompt - ...(skipSystemPromptPrefix - ? [] - : [ - { - type: 'text' as const, - text: getCLISyspromptPrefix({ - isNonInteractive: false, - hasAppendSystemPrompt: false, - }), - }, - ]), - ...(Array.isArray(system) - ? system - : system - ? [{ type: 'text' as const, text: system }] - : []), - ].filter((block): block is TextBlockParam => block !== null) - - let thinkingConfig: BetaThinkingConfigParam | undefined - if (thinking === false) { - thinkingConfig = { type: 'disabled' } - } else if (thinking !== undefined) { - thinkingConfig = { - type: 'enabled', - budget_tokens: Math.min(thinking, max_tokens - 1), - } - } - + const provider = getAPIProvider() const normalizedModel = normalizeModelStringForAPI(model) const start = Date.now() - // biome-ignore lint/plugin: this IS the wrapper that handles OAuth attribution - const response = await client.beta.messages.create( - { - model: normalizedModel, - max_tokens, - system: systemBlocks, - messages, - ...(tools && { tools }), - ...(tool_choice && { tool_choice }), - ...(output_format && { output_config: { format: output_format } }), - ...(temperature !== undefined && { temperature }), - ...(stop_sequences && { stop_sequences }), - ...(thinkingConfig && { thinking: thinkingConfig }), - ...(betas.length > 0 && { betas }), - metadata: getAPIMetadata(), - }, - { signal }, - ) + + const response = + provider === 'openai' || provider === 'grok' + ? await sideQueryViaOpenAICompatibleProvider(opts, provider) + : await (async () => { + const client = await getAnthropicClient({ + maxRetries, + model, + source: 'side_query', + }) + const betas = [...getModelBetas(model)] + // Add structured-outputs beta if using output_format and provider supports it + if ( + output_format && + modelSupportsStructuredOutputs(model) && + !betas.includes(STRUCTURED_OUTPUTS_BETA_HEADER) + ) { + betas.push(STRUCTURED_OUTPUTS_BETA_HEADER) + } + + // Extract first user message text for fingerprint computation. + // This stays first-party only: OpenAI-compatible providers should not + // see Anthropic attribution headers in their prompt text. + const messageText = extractFirstUserMessageText(messages) + + // Compute fingerprint for OAuth attribution + const fingerprint = computeFingerprint(messageText, MACRO.VERSION) + const attributionHeader = getAttributionHeader(fingerprint) + + // Build system as array to keep attribution header in its own block + // (prevents server-side parsing from including system content in cc_entrypoint) + const systemBlocks: TextBlockParam[] = [ + attributionHeader + ? { type: 'text', text: attributionHeader } + : null, + // Skip CLI system prompt prefix for internal classifiers that provide their own prompt + ...(skipSystemPromptPrefix + ? [] + : [ + { + type: 'text' as const, + text: getCLISyspromptPrefix({ + isNonInteractive: false, + hasAppendSystemPrompt: false, + }), + }, + ]), + ...(Array.isArray(system) + ? system + : system + ? [{ type: 'text' as const, text: system }] + : []), + ].filter((block): block is TextBlockParam => block !== null) + + let thinkingConfig: BetaThinkingConfigParam | undefined + if (thinking === false) { + thinkingConfig = { type: 'disabled' } + } else if (thinking !== undefined) { + thinkingConfig = { + type: 'enabled', + budget_tokens: Math.min(thinking, max_tokens - 1), + } + } + + // biome-ignore lint/plugin: this IS the wrapper that handles OAuth attribution + return client.beta.messages.create( + { + model: normalizedModel, + max_tokens, + system: systemBlocks, + messages, + ...(tools && { tools }), + ...(tool_choice && { tool_choice }), + ...(output_format && { + output_config: { format: output_format }, + }), + ...(temperature !== undefined && { temperature }), + ...(stop_sequences && { stop_sequences }), + ...(thinkingConfig && { thinking: thinkingConfig }), + ...(betas.length > 0 && { betas }), + metadata: getAPIMetadata(), + }, + { signal }, + ) + })() const requestId = (response as { _request_id?: string | null })._request_id ?? undefined