| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365 |
- 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;
- 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;
- }
- }
- }
- };
- }
|