|
|
|
|
@@ -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<T = void>(
|
|
|
|
|
root: Root,
|
|
|
|
|
renderer: (done: (result: T) => void) => React.ReactNode,
|
|
|
|
|
): Promise<T> {
|
|
|
|
|
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))
|
|
|
|
|
})
|
|
|
|
|
const done = (result: T): void => void resolve(result);
|
|
|
|
|
root.render(renderer(done));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
@@ -78,12 +71,8 @@ export function showDialog<T = void>(
|
|
|
|
|
* 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 })
|
|
|
|
|
export async function exitWithError(root: Root, message: string, beforeExit?: () => Promise<void>): Promise<never> {
|
|
|
|
|
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<void>
|
|
|
|
|
color?: TextProps['color'];
|
|
|
|
|
exitCode?: number;
|
|
|
|
|
beforeExit?: () => Promise<void>;
|
|
|
|
|
},
|
|
|
|
|
): Promise<never> {
|
|
|
|
|
const { Text } = await import('@anthropic/ink')
|
|
|
|
|
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?.()
|
|
|
|
|
const { Text } = await import('@anthropic/ink');
|
|
|
|
|
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)
|
|
|
|
|
process.exit(exitCode);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
@@ -123,24 +110,26 @@ export function showSetupDialog<T = void>(
|
|
|
|
|
options?: { onChangeAppState?: typeof onChangeAppState },
|
|
|
|
|
): Promise<T> {
|
|
|
|
|
return showDialog<T>(root, done => (
|
|
|
|
|
<AppStateProvider onChangeAppState={options?.onChangeAppState}>
|
|
|
|
|
<KeybindingSetup>{renderer(done)}</KeybindingSetup>
|
|
|
|
|
</AppStateProvider>
|
|
|
|
|
))
|
|
|
|
|
<ThemeProvider
|
|
|
|
|
initialState={getGlobalConfig().theme}
|
|
|
|
|
onThemeSave={setting => saveGlobalConfig(current => ({ ...current, theme: setting }))}
|
|
|
|
|
>
|
|
|
|
|
<AppStateProvider onChangeAppState={options?.onChangeAppState}>
|
|
|
|
|
<KeybindingSetup>{renderer(done)}</KeybindingSetup>
|
|
|
|
|
</AppStateProvider>
|
|
|
|
|
</ThemeProvider>
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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 renderAndRun(root: Root, element: React.ReactNode): Promise<void> {
|
|
|
|
|
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 => (
|
|
|
|
|
<Onboarding
|
|
|
|
|
onDone={() => {
|
|
|
|
|
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 => (
|
|
|
|
|
<TrustDialog commands={commands} onDone={done} />
|
|
|
|
|
))
|
|
|
|
|
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)
|
|
|
|
|
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 => (
|
|
|
|
|
<ClaudeMdExternalIncludesDialog
|
|
|
|
|
onDone={done}
|
|
|
|
|
isStandaloneDialog
|
|
|
|
|
externalIncludes={externalIncludes}
|
|
|
|
|
/>
|
|
|
|
|
))
|
|
|
|
|
<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()
|
|
|
|
|
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<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
|
|
|
|
|
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<boolean>(
|
|
|
|
|
root,
|
|
|
|
|
done => (
|
|
|
|
|
<ApproveApiKey
|
|
|
|
|
customApiKeyTruncated={customApiKeyTruncated}
|
|
|
|
|
onDone={done}
|
|
|
|
|
/>
|
|
|
|
|
),
|
|
|
|
|
done => <ApproveApiKey customApiKeyTruncated={customApiKeyTruncated} onDone={done} />,
|
|
|
|
|
{ onChangeAppState },
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
(permissionMode === 'bypassPermissions' ||
|
|
|
|
|
allowDangerouslySkipPermissions) &&
|
|
|
|
|
(permissionMode === 'bypassPermissions' || allowDangerouslySkipPermissions) &&
|
|
|
|
|
!hasSkipDangerousModePermissionPrompt()
|
|
|
|
|
) {
|
|
|
|
|
const { BypassPermissionsModeDialog } = await import(
|
|
|
|
|
'./components/BypassPermissionsModeDialog.js'
|
|
|
|
|
)
|
|
|
|
|
await showSetupDialog(root, done => (
|
|
|
|
|
<BypassPermissionsModeDialog onAccept={done} />
|
|
|
|
|
))
|
|
|
|
|
const { BypassPermissionsModeDialog } = await import('./components/BypassPermissionsModeDialog.js');
|
|
|
|
|
await showSetupDialog(root, done => <BypassPermissionsModeDialog onAccept={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 => (
|
|
|
|
|
<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()
|
|
|
|
|
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} />
|
|
|
|
|
))
|
|
|
|
|
if (claudeInChrome && !getGlobalConfig().hasCompletedClaudeInChromeOnboarding) {
|
|
|
|
|
const { ClaudeInChromeOnboarding } = await import('./components/ClaudeInChromeOnboarding.js');
|
|
|
|
|
await showSetupDialog(root, done => <ClaudeInChromeOnboarding onDone={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<string, boolean | number | undefined>)
|
|
|
|
|
} as unknown as Record<string, boolean | number | undefined>);
|
|
|
|
|
}
|
|
|
|
|
lastFlickerTime = now
|
|
|
|
|
lastFlickerTime = now;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|