From 71c89e9de443905e446c7b9e58c64010a987ef84 Mon Sep 17 00:00:00 2001 From: Bonerush <2630234655@qq.com> Date: Thu, 30 Apr 2026 16:15:27 +0800 Subject: [PATCH] fix: theme switching always defaults to dark mode Root causes: 1. ThemeProvider was imported but never used in App.tsx and showSetupDialog 2. setThemeConfigCallbacks was never called to inject persistence callbacks 3. Preview/save/cancel theme lifecycle had no provider to coordinate Changes: - Export setThemeConfigCallbacks from @anthropic/ink - Wrap App.tsx children with ThemeProvider (initialState from config, onThemeSave persists) - Wrap showSetupDialog with ThemeProvider for onboarding/trust dialogs - Call setThemeConfigCallbacks in init.ts to register load/save callbacks - Update SnapshotUpdateDialog test to account for new ThemeProvider wrapper Fixes #theme-switching --- packages/@ant/ink/src/index.ts | 1 + src/components/App.tsx | 46 ++- .../__tests__/SnapshotUpdateDialog.test.tsx | 5 +- src/entrypoints/init.ts | 13 +- src/interactiveHelpers.tsx | 305 ++++++++---------- 5 files changed, 168 insertions(+), 202 deletions(-) diff --git a/packages/@ant/ink/src/index.ts b/packages/@ant/ink/src/index.ts index a6300fcb6..0911185bc 100644 --- a/packages/@ant/ink/src/index.ts +++ b/packages/@ant/ink/src/index.ts @@ -154,6 +154,7 @@ export { TerminalWriteProvider, useTerminalNotification, type TerminalNotificati // ============================================================ export { ThemeProvider, + setThemeConfigCallbacks, usePreviewTheme, useTheme, useThemeSetting, diff --git a/src/components/App.tsx b/src/components/App.tsx index e88ea7f10..4e6a5f7d8 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,38 +1,36 @@ -import React from 'react' -import { FpsMetricsProvider } from '../context/fpsMetrics.js' -import { StatsProvider, type StatsStore } from '../context/stats.js' -import { type AppState, AppStateProvider } from '../state/AppState.js' -import { onChangeAppState } from '../state/onChangeAppState.js' -import type { FpsMetrics } from '../utils/fpsTracker.js' -import { ThemeProvider } from '@anthropic/ink' +import React from 'react'; +import { FpsMetricsProvider } from '../context/fpsMetrics.js'; +import { StatsProvider, type StatsStore } from '../context/stats.js'; +import { type AppState, AppStateProvider } from '../state/AppState.js'; +import { onChangeAppState } from '../state/onChangeAppState.js'; +import type { FpsMetrics } from '../utils/fpsTracker.js'; +import { ThemeProvider } from '@anthropic/ink'; +import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'; type Props = { - getFpsMetrics: () => FpsMetrics | undefined - stats?: StatsStore - initialState: AppState - children: React.ReactNode -} + getFpsMetrics: () => FpsMetrics | undefined; + stats?: StatsStore; + initialState: AppState; + children: React.ReactNode; +}; /** * Top-level wrapper for interactive sessions. * Provides FPS metrics, stats context, and app state to the component tree. */ -export function App({ - getFpsMetrics, - stats, - initialState, - children, -}: Props): React.ReactNode { +export function App({ getFpsMetrics, stats, initialState, children }: Props): React.ReactNode { return ( - - {children} + + saveGlobalConfig(current => ({ ...current, theme: setting }))} + > + {children} + - ) + ); } diff --git a/src/components/agents/__tests__/SnapshotUpdateDialog.test.tsx b/src/components/agents/__tests__/SnapshotUpdateDialog.test.tsx index b38f947fe..3dfc7bd47 100644 --- a/src/components/agents/__tests__/SnapshotUpdateDialog.test.tsx +++ b/src/components/agents/__tests__/SnapshotUpdateDialog.test.tsx @@ -5,7 +5,10 @@ import { buildMergePrompt, SnapshotUpdateDialog } from '../SnapshotUpdateDialog. import { Select } from '../../CustomSelect/index.js'; function getSnapshotDialogFromRenderedTree(rendered: React.ReactElement) { - const appStateProvider = rendered as React.ReactElement<{ + const themeProvider = rendered as React.ReactElement<{ + children: React.ReactElement; + }>; + const appStateProvider = themeProvider.props.children as React.ReactElement<{ children: React.ReactElement; }>; const keybindingSetup = appStateProvider.props.children as React.ReactElement<{ diff --git a/src/entrypoints/init.ts b/src/entrypoints/init.ts index c3125b8b7..a610c1c25 100644 --- a/src/entrypoints/init.ts +++ b/src/entrypoints/init.ts @@ -20,7 +20,12 @@ import { import { preconnectAnthropicApi } from '../utils/apiPreconnect.js' import { applyExtraCACertsFromConfig } from '../utils/caCertsConfig.js' import { registerCleanup } from '../utils/cleanupRegistry.js' -import { enableConfigs, recordFirstStartTime } from '../utils/config.js' +import { + enableConfigs, + getGlobalConfig, + recordFirstStartTime, + saveGlobalConfig, +} from '../utils/config.js' import { logForDebugging } from '../utils/debug.js' import { detectCurrentRepository } from '../utils/detectRepository.js' import { logForDiagnosticsNoPII } from '../utils/diagLogs.js' @@ -51,6 +56,7 @@ import { setShellIfWindows } from '../utils/windowsPaths.js' import { initSentry } from '../utils/sentry.js' import { initUser } from '../utils/user.js' import { initLangfuse, shutdownLangfuse } from '../services/langfuse/index.js' +import { setThemeConfigCallbacks } from '@anthropic/ink' // initialize1PEventLogging is dynamically imported to defer OpenTelemetry sdk-logs/resources @@ -66,6 +72,11 @@ export const init = memoize(async (): Promise => { try { const configsStart = Date.now() enableConfigs() + setThemeConfigCallbacks({ + loadTheme: () => getGlobalConfig().theme, + saveTheme: setting => + saveGlobalConfig(current => ({ ...current, theme: setting })), + }) logForDiagnosticsNoPII('info', 'init_configs_enabled', { duration_ms: Date.now() - configsStart, }) diff --git a/src/interactiveHelpers.tsx b/src/interactiveHelpers.tsx index 1e2d6ad44..d7ad64a6a 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,63 +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 { ThemeProvider } from '@anthropic/ink'; +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 { - 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 { 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)); + }); } /** @@ -78,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 }); } /** @@ -96,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,24 +110,26 @@ export function showSetupDialog( options?: { onChangeAppState?: typeof onChangeAppState }, ): Promise { return showDialog(root, done => ( - - {renderer(done)} - - )) + saveGlobalConfig(current => ({ ...current, theme: setting }))} + > + + {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( @@ -156,29 +145,29 @@ export async function showSetupScreens( 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. @@ -192,83 +181,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; } } @@ -276,36 +253,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 => ); } // --dangerously-load-development-channels confirmation. On accept, append @@ -313,72 +278,60 @@ export async function showSetupScreens( // is NOT bypassed — gateChannelServer() still runs; this flag only exists // to sidestep the --channels approved-server allowlist. if (devChannels && devChannels.length > 0) { - 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 @@ -390,30 +343,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; } }, }, - } + }; }