interactiveHelpers.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. import { feature } from 'bun:bundle';
  2. import { appendFileSync } from 'fs';
  3. import React from 'react';
  4. import { logEvent } from 'src/services/analytics/index.js';
  5. import { gracefulShutdown, gracefulShutdownSync } from 'src/utils/gracefulShutdown.js';
  6. import { type ChannelEntry, getAllowedChannels, setAllowedChannels, setHasDevChannels, setSessionTrustAccepted, setStatsStore } from './bootstrap/state.js';
  7. import type { Command } from './commands.js';
  8. import { createStatsStore, type StatsStore } from './context/stats.js';
  9. import { getSystemContext } from './context.js';
  10. import { initializeTelemetryAfterTrust } from './entrypoints/init.js';
  11. import { isSynchronizedOutputSupported } from './ink/terminal.js';
  12. import type { RenderOptions, Root, TextProps } from './ink.js';
  13. import { KeybindingSetup } from './keybindings/KeybindingProviderSetup.js';
  14. import { startDeferredPrefetches } from './main.js';
  15. import { checkGate_CACHED_OR_BLOCKING, initializeGrowthBook, resetGrowthBook } from './services/analytics/growthbook.js';
  16. import { isQualifiedForGrove } from './services/api/grove.js';
  17. import { handleMcpjsonServerApprovals } from './services/mcpServerApproval.js';
  18. import { AppStateProvider } from './state/AppState.js';
  19. import { onChangeAppState } from './state/onChangeAppState.js';
  20. import { normalizeApiKeyForConfig } from './utils/authPortable.js';
  21. import { getExternalClaudeMdIncludes, getMemoryFiles, shouldShowClaudeMdExternalIncludesWarning } from './utils/claudemd.js';
  22. import { checkHasTrustDialogAccepted, getCustomApiKeyStatus, getGlobalConfig, saveGlobalConfig } from './utils/config.js';
  23. import { updateDeepLinkTerminalPreference } from './utils/deepLink/terminalPreference.js';
  24. import { isEnvTruthy, isRunningOnHomespace } from './utils/envUtils.js';
  25. import { type FpsMetrics, FpsTracker } from './utils/fpsTracker.js';
  26. import { updateGithubRepoPathMapping } from './utils/githubRepoPathMapping.js';
  27. import { applyConfigEnvironmentVariables } from './utils/managedEnv.js';
  28. import type { PermissionMode } from './utils/permissions/PermissionMode.js';
  29. import { getBaseRenderOptions } from './utils/renderOptions.js';
  30. import { getSettingsWithAllErrors } from './utils/settings/allErrors.js';
  31. import { hasAutoModeOptIn, hasSkipDangerousModePermissionPrompt } from './utils/settings/settings.js';
  32. export function completeOnboarding(): void {
  33. saveGlobalConfig(current => ({
  34. ...current,
  35. hasCompletedOnboarding: true,
  36. lastOnboardingVersion: MACRO.VERSION
  37. }));
  38. }
  39. export function showDialog<T = void>(root: Root, renderer: (done: (result: T) => void) => React.ReactNode): Promise<T> {
  40. return new Promise<T>(resolve => {
  41. const done = (result: T): void => void resolve(result);
  42. root.render(renderer(done));
  43. });
  44. }
  45. /**
  46. * Render an error message through Ink, then unmount and exit.
  47. * Use this for fatal errors after the Ink root has been created —
  48. * console.error is swallowed by Ink's patchConsole, so we render
  49. * through the React tree instead.
  50. */
  51. export async function exitWithError(root: Root, message: string, beforeExit?: () => Promise<void>): Promise<never> {
  52. return exitWithMessage(root, message, {
  53. color: 'error',
  54. beforeExit
  55. });
  56. }
  57. /**
  58. * Render a message through Ink, then unmount and exit.
  59. * Use this for messages after the Ink root has been created —
  60. * console output is swallowed by Ink's patchConsole, so we render
  61. * through the React tree instead.
  62. */
  63. export async function exitWithMessage(root: Root, message: string, options?: {
  64. color?: TextProps['color'];
  65. exitCode?: number;
  66. beforeExit?: () => Promise<void>;
  67. }): Promise<never> {
  68. const {
  69. Text
  70. } = await import('./ink.js');
  71. const color = options?.color;
  72. const exitCode = options?.exitCode ?? 1;
  73. root.render(color ? <Text color={color}>{message}</Text> : <Text>{message}</Text>);
  74. root.unmount();
  75. await options?.beforeExit?.();
  76. // eslint-disable-next-line custom-rules/no-process-exit -- exit after Ink unmount
  77. process.exit(exitCode);
  78. }
  79. /**
  80. * Show a setup dialog wrapped in AppStateProvider + KeybindingSetup.
  81. * Reduces boilerplate in showSetupScreens() where every dialog needs these wrappers.
  82. */
  83. export function showSetupDialog<T = void>(root: Root, renderer: (done: (result: T) => void) => React.ReactNode, options?: {
  84. onChangeAppState?: typeof onChangeAppState;
  85. }): Promise<T> {
  86. return showDialog<T>(root, done => <AppStateProvider onChangeAppState={options?.onChangeAppState}>
  87. <KeybindingSetup>{renderer(done)}</KeybindingSetup>
  88. </AppStateProvider>);
  89. }
  90. /**
  91. * Render the main UI into the root and wait for it to exit.
  92. * Handles the common epilogue: start deferred prefetches, wait for exit, graceful shutdown.
  93. */
  94. export async function renderAndRun(root: Root, element: React.ReactNode): Promise<void> {
  95. root.render(element);
  96. startDeferredPrefetches();
  97. await root.waitUntilExit();
  98. await gracefulShutdown(0);
  99. }
  100. export async function showSetupScreens(root: Root, permissionMode: PermissionMode, allowDangerouslySkipPermissions: boolean, commands?: Command[], claudeInChrome?: boolean, devChannels?: ChannelEntry[]): Promise<boolean> {
  101. if (("production" as string) === 'test' || isEnvTruthy(false) || process.env.IS_DEMO // Skip onboarding in demo mode
  102. ) {
  103. return false;
  104. }
  105. const config = getGlobalConfig();
  106. let onboardingShown = false;
  107. if (!config.theme || !config.hasCompletedOnboarding // always show onboarding at least once
  108. ) {
  109. onboardingShown = true;
  110. const {
  111. Onboarding
  112. } = await import('./components/Onboarding.js');
  113. await showSetupDialog(root, done => <Onboarding onDone={() => {
  114. completeOnboarding();
  115. void done();
  116. }} />, {
  117. onChangeAppState
  118. });
  119. }
  120. // Always show the trust dialog in interactive sessions, regardless of permission mode.
  121. // The trust dialog is the workspace trust boundary — it warns about untrusted repos
  122. // and checks CLAUDE.md external includes. bypassPermissions mode
  123. // only affects tool execution permissions, not workspace trust.
  124. // Note: non-interactive sessions (CI/CD with -p) never reach showSetupScreens at all.
  125. // Skip permission checks in claubbit
  126. if (!isEnvTruthy(process.env.CLAUBBIT)) {
  127. // Fast-path: skip TrustDialog import+render when CWD is already trusted.
  128. // If it returns true, the TrustDialog would auto-resolve regardless of
  129. // security features, so we can skip the dynamic import and render cycle.
  130. if (!checkHasTrustDialogAccepted()) {
  131. const {
  132. TrustDialog
  133. } = await import('./components/TrustDialog/TrustDialog.js');
  134. await showSetupDialog(root, done => <TrustDialog commands={commands} onDone={done} />);
  135. }
  136. // Signal that trust has been verified for this session.
  137. // GrowthBook checks this to decide whether to include auth headers.
  138. setSessionTrustAccepted(true);
  139. // Reset and reinitialize GrowthBook after trust is established.
  140. // Defense for login/logout: clears any prior client so the next init
  141. // picks up fresh auth headers.
  142. resetGrowthBook();
  143. void initializeGrowthBook();
  144. // Now that trust is established, prefetch system context if it wasn't already
  145. void getSystemContext();
  146. // If settings are valid, check for any mcp.json servers that need approval
  147. const {
  148. errors: allErrors
  149. } = getSettingsWithAllErrors();
  150. if (allErrors.length === 0) {
  151. await handleMcpjsonServerApprovals(root);
  152. }
  153. // Check for claude.md includes that need approval
  154. if (await shouldShowClaudeMdExternalIncludesWarning()) {
  155. const externalIncludes = getExternalClaudeMdIncludes(await getMemoryFiles(true));
  156. const {
  157. ClaudeMdExternalIncludesDialog
  158. } = await import('./components/ClaudeMdExternalIncludesDialog.js');
  159. await showSetupDialog(root, done => <ClaudeMdExternalIncludesDialog onDone={done} isStandaloneDialog externalIncludes={externalIncludes} />);
  160. }
  161. }
  162. // Track current repo path for teleport directory switching (fire-and-forget)
  163. // This must happen AFTER trust to prevent untrusted directories from poisoning the mapping
  164. void updateGithubRepoPathMapping();
  165. if (feature('LODESTONE')) {
  166. updateDeepLinkTerminalPreference();
  167. }
  168. // Apply full environment variables after trust dialog is accepted OR in bypass mode
  169. // In bypass mode (CI/CD, automation), we trust the environment so apply all variables
  170. // In normal mode, this happens after the trust dialog is accepted
  171. // This includes potentially dangerous environment variables from untrusted sources
  172. applyConfigEnvironmentVariables();
  173. // Initialize telemetry after env vars are applied so OTEL endpoint env vars and
  174. // otelHeadersHelper (which requires trust to execute) are available.
  175. // Defer to next tick so the OTel dynamic import resolves after first render
  176. // instead of during the pre-render microtask queue.
  177. setImmediate(() => initializeTelemetryAfterTrust());
  178. if (await isQualifiedForGrove()) {
  179. const {
  180. GroveDialog
  181. } = await import('src/components/grove/Grove.js');
  182. const decision = await showSetupDialog<string>(root, done => <GroveDialog showIfAlreadyViewed={false} location={onboardingShown ? 'onboarding' : 'policy_update_modal'} onDone={done} />);
  183. if (decision === 'escape') {
  184. logEvent('tengu_grove_policy_exited', {});
  185. gracefulShutdownSync(0);
  186. return false;
  187. }
  188. }
  189. // Check for custom API key
  190. // On homespace, ANTHROPIC_API_KEY is preserved in process.env for child
  191. // processes but ignored by Claude Code itself (see auth.ts).
  192. if (process.env.ANTHROPIC_API_KEY && !isRunningOnHomespace()) {
  193. const customApiKeyTruncated = normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY);
  194. const keyStatus = getCustomApiKeyStatus(customApiKeyTruncated);
  195. if (keyStatus === 'new') {
  196. const {
  197. ApproveApiKey
  198. } = await import('./components/ApproveApiKey.js');
  199. await showSetupDialog<boolean>(root, done => <ApproveApiKey customApiKeyTruncated={customApiKeyTruncated} onDone={done} />, {
  200. onChangeAppState
  201. });
  202. }
  203. }
  204. if ((permissionMode === 'bypassPermissions' || allowDangerouslySkipPermissions) && !hasSkipDangerousModePermissionPrompt()) {
  205. const {
  206. BypassPermissionsModeDialog
  207. } = await import('./components/BypassPermissionsModeDialog.js');
  208. await showSetupDialog(root, done => <BypassPermissionsModeDialog onAccept={done} />);
  209. }
  210. if (feature('TRANSCRIPT_CLASSIFIER')) {
  211. // Only show the opt-in dialog if auto mode actually resolved — if the
  212. // gate denied it (org not allowlisted, settings disabled), showing
  213. // consent for an unavailable feature is pointless. The
  214. // verifyAutoModeGateAccess notification will explain why instead.
  215. if (permissionMode === 'auto' && !hasAutoModeOptIn()) {
  216. const {
  217. AutoModeOptInDialog
  218. } = await import('./components/AutoModeOptInDialog.js');
  219. await showSetupDialog(root, done => <AutoModeOptInDialog onAccept={done} onDecline={() => gracefulShutdownSync(1)} declineExits />);
  220. }
  221. }
  222. // --dangerously-load-development-channels confirmation. On accept, append
  223. // dev channels to any --channels list already set in main.tsx. Org policy
  224. // is NOT bypassed — gateChannelServer() still runs; this flag only exists
  225. // to sidestep the --channels approved-server allowlist.
  226. if (feature('KAIROS') || feature('KAIROS_CHANNELS')) {
  227. // gateChannelServer and ChannelsNotice read tengu_harbor after this
  228. // function returns. A cold disk cache (fresh install, or first run after
  229. // the flag was added server-side) defaults to false and silently drops
  230. // channel notifications for the whole session — gh#37026.
  231. // checkGate_CACHED_OR_BLOCKING returns immediately if disk already says
  232. // true; only blocks on a cold/stale-false cache (awaits the same memoized
  233. // initializeGrowthBook promise fired earlier). Also warms the
  234. // isChannelsEnabled() check in the dev-channels dialog below.
  235. if (getAllowedChannels().length > 0 || (devChannels?.length ?? 0) > 0) {
  236. await checkGate_CACHED_OR_BLOCKING('tengu_harbor');
  237. }
  238. if (devChannels && devChannels.length > 0) {
  239. const [{
  240. isChannelsEnabled
  241. }, {
  242. getClaudeAIOAuthTokens
  243. }] = await Promise.all([import('./services/mcp/channelAllowlist.js'), import('./utils/auth.js')]);
  244. // Skip the dialog when channels are blocked (tengu_harbor off or no
  245. // OAuth) — accepting then immediately seeing "not available" in
  246. // ChannelsNotice is worse than no dialog. Append entries anyway so
  247. // ChannelsNotice renders the blocked branch with the dev entries
  248. // named. dev:true here is for the flag label in ChannelsNotice
  249. // (hasNonDev check); the allowlist bypass it also grants is moot
  250. // since the gate blocks upstream.
  251. if (!isChannelsEnabled() || !getClaudeAIOAuthTokens()?.accessToken) {
  252. setAllowedChannels([...getAllowedChannels(), ...devChannels.map(c => ({
  253. ...c,
  254. dev: true
  255. }))]);
  256. setHasDevChannels(true);
  257. } else {
  258. const {
  259. DevChannelsDialog
  260. } = await import('./components/DevChannelsDialog.js');
  261. await showSetupDialog(root, done => <DevChannelsDialog channels={devChannels} onAccept={() => {
  262. // Mark dev entries per-entry so the allowlist bypass doesn't leak
  263. // to --channels entries when both flags are passed.
  264. setAllowedChannels([...getAllowedChannels(), ...devChannels.map(c => ({
  265. ...c,
  266. dev: true
  267. }))]);
  268. setHasDevChannels(true);
  269. void done();
  270. }} />);
  271. }
  272. }
  273. }
  274. // Show Chrome onboarding for first-time Claude in Chrome users
  275. if (claudeInChrome && !getGlobalConfig().hasCompletedClaudeInChromeOnboarding) {
  276. const {
  277. ClaudeInChromeOnboarding
  278. } = await import('./components/ClaudeInChromeOnboarding.js');
  279. await showSetupDialog(root, done => <ClaudeInChromeOnboarding onDone={done} />);
  280. }
  281. return onboardingShown;
  282. }
  283. export function getRenderContext(exitOnCtrlC: boolean): {
  284. renderOptions: RenderOptions;
  285. getFpsMetrics: () => FpsMetrics | undefined;
  286. stats: StatsStore;
  287. } {
  288. let lastFlickerTime = 0;
  289. const baseOptions = getBaseRenderOptions(exitOnCtrlC);
  290. // Log analytics event when stdin override is active
  291. if (baseOptions.stdin) {
  292. logEvent('tengu_stdin_interactive', {});
  293. }
  294. const fpsTracker = new FpsTracker();
  295. const stats = createStatsStore();
  296. setStatsStore(stats);
  297. // Bench mode: when set, append per-frame phase timings as JSONL for
  298. // offline analysis by bench/repl-scroll.ts. Captures the full TUI
  299. // render pipeline (yoga → screen buffer → diff → optimize → stdout)
  300. // so perf work on any phase can be validated against real user flows.
  301. const frameTimingLogPath = process.env.CLAUDE_CODE_FRAME_TIMING_LOG;
  302. return {
  303. getFpsMetrics: () => fpsTracker.getMetrics(),
  304. stats,
  305. renderOptions: {
  306. ...baseOptions,
  307. onFrame: event => {
  308. fpsTracker.record(event.durationMs);
  309. stats.observe('frame_duration_ms', event.durationMs);
  310. if (frameTimingLogPath && event.phases) {
  311. // Bench-only env-var-gated path: sync write so no frames dropped
  312. // on abrupt exit. ~100 bytes at ≤60fps is negligible. rss/cpu are
  313. // single syscalls; cpu is cumulative — bench side computes delta.
  314. const line =
  315. // eslint-disable-next-line custom-rules/no-direct-json-operations -- tiny object, hot bench path
  316. JSON.stringify({
  317. total: event.durationMs,
  318. ...event.phases,
  319. rss: process.memoryUsage.rss(),
  320. cpu: process.cpuUsage()
  321. }) + '\n';
  322. // eslint-disable-next-line custom-rules/no-sync-fs -- bench-only, sync so no frames dropped on exit
  323. appendFileSync(frameTimingLogPath, line);
  324. }
  325. // Skip flicker reporting for terminals with synchronized output —
  326. // DEC 2026 buffers between BSU/ESU so clear+redraw is atomic.
  327. if (isSynchronizedOutputSupported()) {
  328. return;
  329. }
  330. for (const flicker of event.flickers) {
  331. if (flicker.reason === 'resize') {
  332. continue;
  333. }
  334. const now = Date.now();
  335. if (now - lastFlickerTime < 1000) {
  336. logEvent('tengu_flicker', {
  337. desiredHeight: flicker.desiredHeight,
  338. actualHeight: flicker.availableHeight,
  339. reason: flicker.reason
  340. } as unknown as Record<string, boolean | number | undefined>);
  341. }
  342. lastFlickerTime = now;
  343. }
  344. }
  345. }
  346. };
  347. }