claude-code/src/interactiveHelpers.tsx
weiqianpu cf1707bbb0 feat: 开源发布 — 完整 README、MIT 许可证、22+ 大模型厂商支持
- 重写 README.md:项目简介、快速开始、22+ 厂商列表、功能特性、架构说明、配置指南、FAQ、贡献指南
- 新增 MIT LICENSE
- 包含全部源码与文档
2026-04-02 02:34:24 +00:00

374 lines
17 KiB
TypeScript
Executable File

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, setAllowedChannels, 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 './ink/terminal.js';
import type { RenderOptions, Root, TextProps } from './ink.js';
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';
import { getExternalClaudeMdIncludes, getMemoryFiles, shouldShowClaudeMdExternalIncludesWarning } 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';
export function completeOnboarding(): void {
saveGlobalConfig(current => ({
...current,
hasCompletedOnboarding: true,
lastOnboardingVersion: MACRO.VERSION
}));
}
export function showDialog<T = void>(root: Root, renderer: (done: (result: T) => void) => React.ReactNode): Promise<T> {
return new Promise<T>(resolve => {
const done = (result: T): void => void resolve(result);
root.render(renderer(done));
});
}
/**
* Render an error message through Ink, then unmount and exit.
* Use this for fatal errors after the Ink root has been created —
* 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<void>): Promise<never> {
return exitWithMessage(root, message, {
color: 'error',
beforeExit
});
}
/**
* Render a message through Ink, then unmount and exit.
* Use this for messages after the Ink root has been created —
* console output is swallowed by Ink's patchConsole, so we render
* through the React tree instead.
*/
export async function exitWithMessage(root: Root, message: string, options?: {
color?: TextProps['color'];
exitCode?: number;
beforeExit?: () => Promise<void>;
}): Promise<never> {
const {
Text
} = await import('./ink.js');
const color = options?.color;
const exitCode = options?.exitCode ?? 1;
root.render(color ? <Text color={color}>{message}</Text> : <Text>{message}</Text>);
root.unmount();
await options?.beforeExit?.();
// eslint-disable-next-line custom-rules/no-process-exit -- exit after Ink unmount
process.exit(exitCode);
}
/**
* Show a setup dialog wrapped in AppStateProvider + KeybindingSetup.
* Reduces boilerplate in showSetupScreens() where every dialog needs these wrappers.
*/
export function showSetupDialog<T = void>(root: Root, renderer: (done: (result: T) => void) => React.ReactNode, options?: {
onChangeAppState?: typeof onChangeAppState;
}): Promise<T> {
return showDialog<T>(root, done => <AppStateProvider onChangeAppState={options?.onChangeAppState}>
<KeybindingSetup>{renderer(done)}</KeybindingSetup>
</AppStateProvider>);
}
/**
* 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<void> {
root.render(element);
startDeferredPrefetches();
await root.waitUntilExit();
await gracefulShutdown(0);
}
export async function showSetupScreens(root: Root, permissionMode: PermissionMode, allowDangerouslySkipPermissions: boolean, commands?: Command[], claudeInChrome?: boolean, devChannels?: ChannelEntry[]): Promise<boolean> {
if (("production" as string) === 'test' || isEnvTruthy(false) || process.env.IS_DEMO // Skip onboarding in demo mode
) {
return false;
}
const config = getGlobalConfig();
let onboardingShown = false;
// Skip onboarding entirely for OpenAI-compatible providers (Ollama, DeepSeek, etc.)
const { getAPIProvider } = await import('./utils/model/providers.js');
if (getAPIProvider() === 'openai') {
if (!config.hasCompletedOnboarding) completeOnboarding();
return false;
}
if (!config.theme || !config.hasCompletedOnboarding // always show onboarding at least once
) {
onboardingShown = true;
const {
Onboarding
} = await import('./components/Onboarding.js');
await showSetupDialog(root, done => <Onboarding onDone={() => {
completeOnboarding();
void done();
}} />, {
onChangeAppState
});
}
// Always show the trust dialog in interactive sessions, regardless of permission mode.
// The trust dialog is the workspace trust boundary — it warns about untrusted repos
// and checks CLAUDE.md external includes. bypassPermissions mode
// only affects tool execution permissions, not workspace trust.
// Note: non-interactive sessions (CI/CD with -p) never reach showSetupScreens at all.
// Skip permission checks in claubbit
if (!isEnvTruthy(process.env.CLAUBBIT)) {
// Fast-path: skip TrustDialog import+render when CWD is already trusted.
// 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 => <TrustDialog commands={commands} onDone={done} />);
}
// Signal that trust has been verified for this session.
// GrowthBook checks this to decide whether to include auth headers.
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();
// Now that trust is established, prefetch system context if it wasn't already
void getSystemContext();
// If settings are valid, check for any mcp.json servers that need approval
const {
errors: allErrors
} = getSettingsWithAllErrors();
if (allErrors.length === 0) {
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');
await showSetupDialog(root, done => <ClaudeMdExternalIncludesDialog onDone={done} isStandaloneDialog externalIncludes={externalIncludes} />);
}
}
// 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();
if (feature('LODESTONE')) {
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();
// 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());
if (await isQualifiedForGrove()) {
const {
GroveDialog
} = await import('src/components/grove/Grove.js');
const decision = await showSetupDialog<string>(root, done => <GroveDialog showIfAlreadyViewed={false} location={onboardingShown ? 'onboarding' : 'policy_update_modal'} onDone={done} />);
if (decision === 'escape') {
logEvent('tengu_grove_policy_exited', {});
gracefulShutdownSync(0);
return false;
}
}
// Check for custom API key
// 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);
if (keyStatus === 'new') {
const {
ApproveApiKey
} = await import('./components/ApproveApiKey.js');
await showSetupDialog<boolean>(root, done => <ApproveApiKey customApiKeyTruncated={customApiKeyTruncated} onDone={done} />, {
onChangeAppState
});
}
}
if ((permissionMode === 'bypassPermissions' || allowDangerouslySkipPermissions) && !hasSkipDangerousModePermissionPrompt()) {
const {
BypassPermissionsModeDialog
} = await import('./components/BypassPermissionsModeDialog.js');
await showSetupDialog(root, done => <BypassPermissionsModeDialog onAccept={done} />);
}
if (feature('TRANSCRIPT_CLASSIFIER')) {
// Only show the opt-in dialog if auto mode actually resolved — if the
// gate denied it (org not allowlisted, settings disabled), showing
// 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');
await showSetupDialog(root, done => <AutoModeOptInDialog onAccept={done} onDecline={() => gracefulShutdownSync(1)} declineExits />);
}
}
// --dangerously-load-development-channels confirmation. On accept, append
// dev channels to any --channels list already set in main.tsx. Org policy
// is NOT bypassed — gateChannelServer() still runs; this flag only exists
// to sidestep the --channels approved-server allowlist.
if (feature('KAIROS') || feature('KAIROS_CHANNELS')) {
// gateChannelServer and ChannelsNotice read tengu_harbor after this
// function returns. A cold disk cache (fresh install, or first run after
// the flag was added server-side) defaults to false and silently drops
// channel notifications for the whole session — gh#37026.
// checkGate_CACHED_OR_BLOCKING returns immediately if disk already says
// true; only blocks on a cold/stale-false cache (awaits the same memoized
// 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');
}
if (devChannels && devChannels.length > 0) {
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
// ChannelsNotice renders the blocked branch with the dev entries
// named. dev:true here is for the flag label in ChannelsNotice
// (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);
} else {
const {
DevChannelsDialog
} = await import('./components/DevChannelsDialog.js');
await showSetupDialog(root, done => <DevChannelsDialog channels={devChannels} onAccept={() => {
// 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();
}} />);
}
}
}
// 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 => <ClaudeInChromeOnboarding onDone={done} />);
}
return onboardingShown;
}
export function getRenderContext(exitOnCtrlC: boolean): {
renderOptions: RenderOptions;
getFpsMetrics: () => FpsMetrics | undefined;
stats: StatsStore;
} {
let lastFlickerTime = 0;
const baseOptions = getBaseRenderOptions(exitOnCtrlC);
// Log analytics event when stdin override is active
if (baseOptions.stdin) {
logEvent('tengu_stdin_interactive', {});
}
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;
return {
getFpsMetrics: () => fpsTracker.getMetrics(),
stats,
renderOptions: {
...baseOptions,
onFrame: event => {
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
// single syscalls; cpu is cumulative — bench side computes delta.
const line =
// eslint-disable-next-line custom-rules/no-direct-json-operations -- tiny object, hot bench path
JSON.stringify({
total: event.durationMs,
...event.phases,
rss: process.memoryUsage.rss(),
cpu: process.cpuUsage()
}) + '\n';
// eslint-disable-next-line custom-rules/no-sync-fs -- bench-only, sync so no frames dropped on exit
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;
}
for (const flicker of event.flickers) {
if (flicker.reason === 'resize') {
continue;
}
const now = Date.now();
if (now - lastFlickerTime < 1000) {
logEvent('tengu_flicker', {
desiredHeight: flicker.desiredHeight,
actualHeight: flicker.availableHeight,
reason: flicker.reason
} as unknown as Record<string, boolean | number | undefined>);
}
lastFlickerTime = now;
}
}
}
};
}