style(B1-6): 格式化 main/entrypoints/utils/moreright (21 files)

纯格式化:移除分号、React Compiler import、import 多行展开。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
claude-code-best
2026-04-04 22:50:29 +08:00
parent a574ea205b
commit 0e541af24b
21 changed files with 10100 additions and 6888 deletions

View File

@@ -6,62 +6,91 @@
* Part of the main.tsx React/JSX extraction effort. See sibling PRs
* perf/extract-interactive-helpers and perf/launch-repl.
*/
import React from 'react';
import type { AssistantSession } from './assistant/sessionDiscovery.js';
import type { StatsStore } from './context/stats.js';
import type { Root } from './ink.js';
import { renderAndRun, showSetupDialog } from './interactiveHelpers.js';
import { KeybindingSetup } from './keybindings/KeybindingProviderSetup.js';
import type { AppState } from './state/AppStateStore.js';
import type { AgentMemoryScope } from './tools/AgentTool/agentMemory.js';
import type { TeleportRemoteResponse } from './utils/conversationRecovery.js';
import type { FpsMetrics } from './utils/fpsTracker.js';
import type { ValidationError } from './utils/settings/validation.js';
import React from 'react'
import type { AssistantSession } from './assistant/sessionDiscovery.js'
import type { StatsStore } from './context/stats.js'
import type { Root } from './ink.js'
import { renderAndRun, showSetupDialog } from './interactiveHelpers.js'
import { KeybindingSetup } from './keybindings/KeybindingProviderSetup.js'
import type { AppState } from './state/AppStateStore.js'
import type { AgentMemoryScope } from './tools/AgentTool/agentMemory.js'
import type { TeleportRemoteResponse } from './utils/conversationRecovery.js'
import type { FpsMetrics } from './utils/fpsTracker.js'
import type { ValidationError } from './utils/settings/validation.js'
// Type-only access to ResumeConversation's Props via the module type.
// No runtime cost - erased at compile time.
type ResumeConversationProps = React.ComponentProps<typeof import('./screens/ResumeConversation.js').ResumeConversation>;
type ResumeConversationProps = React.ComponentProps<
typeof import('./screens/ResumeConversation.js').ResumeConversation
>
/**
* Site ~3173: SnapshotUpdateDialog (agent memory snapshot update prompt).
* Original callback wiring: onComplete={done}, onCancel={() => done('keep')}.
*/
export async function launchSnapshotUpdateDialog(root: Root, props: {
agentType: string;
scope: AgentMemoryScope;
snapshotTimestamp: string;
}): Promise<'merge' | 'keep' | 'replace'> {
const {
SnapshotUpdateDialog
} = await import('./components/agents/SnapshotUpdateDialog.js');
return showSetupDialog<'merge' | 'keep' | 'replace'>(root, done => <SnapshotUpdateDialog agentType={props.agentType} scope={props.scope} snapshotTimestamp={props.snapshotTimestamp} onComplete={done} onCancel={() => done('keep')} />);
export async function launchSnapshotUpdateDialog(
root: Root,
props: {
agentType: string
scope: AgentMemoryScope
snapshotTimestamp: string
},
): Promise<'merge' | 'keep' | 'replace'> {
const { SnapshotUpdateDialog } = await import(
'./components/agents/SnapshotUpdateDialog.js'
)
return showSetupDialog<'merge' | 'keep' | 'replace'>(root, done => (
<SnapshotUpdateDialog
agentType={props.agentType}
scope={props.scope}
snapshotTimestamp={props.snapshotTimestamp}
onComplete={done}
onCancel={() => done('keep')}
/>
))
}
/**
* Site ~3250: InvalidSettingsDialog (settings validation errors).
* Original callback wiring: onContinue={done}, onExit passed through from caller.
*/
export async function launchInvalidSettingsDialog(root: Root, props: {
settingsErrors: ValidationError[];
onExit: () => void;
}): Promise<void> {
const {
InvalidSettingsDialog
} = await import('./components/InvalidSettingsDialog.js');
return showSetupDialog(root, done => <InvalidSettingsDialog settingsErrors={props.settingsErrors} onContinue={done} onExit={props.onExit} />);
export async function launchInvalidSettingsDialog(
root: Root,
props: {
settingsErrors: ValidationError[]
onExit: () => void
},
): Promise<void> {
const { InvalidSettingsDialog } = await import(
'./components/InvalidSettingsDialog.js'
)
return showSetupDialog(root, done => (
<InvalidSettingsDialog
settingsErrors={props.settingsErrors}
onContinue={done}
onExit={props.onExit}
/>
))
}
/**
* Site ~4229: AssistantSessionChooser (pick a bridge session to attach to).
* Original callback wiring: onSelect={id => done(id)}, onCancel={() => done(null)}.
*/
export async function launchAssistantSessionChooser(root: Root, props: {
sessions: AssistantSession[];
}): Promise<string | null> {
const {
AssistantSessionChooser
} = await import('./assistant/AssistantSessionChooser.js');
return showSetupDialog<string | null>(root, done => <AssistantSessionChooser sessions={props.sessions} onSelect={id => done(id)} onCancel={() => done(null)} />);
export async function launchAssistantSessionChooser(
root: Root,
props: { sessions: AssistantSession[] },
): Promise<string | null> {
const { AssistantSessionChooser } = await import(
'./assistant/AssistantSessionChooser.js'
)
return showSetupDialog<string | null>(root, done => (
<AssistantSessionChooser
sessions={props.sessions}
onSelect={id => done(id)}
onCancel={() => done(null)}
/>
))
}
/**
@@ -70,43 +99,71 @@ export async function launchAssistantSessionChooser(root: Root, props: {
* success, null on cancel. Rejects on install failure so the caller can
* distinguish errors from user cancellation.
*/
export async function launchAssistantInstallWizard(root: Root): Promise<string | null> {
const {
NewInstallWizard,
computeDefaultInstallDir
} = await import('./commands/assistant/assistant.js');
const defaultDir = await computeDefaultInstallDir();
let rejectWithError: (reason: Error) => void;
export async function launchAssistantInstallWizard(
root: Root,
): Promise<string | null> {
const { NewInstallWizard, computeDefaultInstallDir } = await import(
'./commands/assistant/assistant.js'
)
const defaultDir = await computeDefaultInstallDir()
let rejectWithError: (reason: Error) => void
const errorPromise = new Promise<never>((_, reject) => {
rejectWithError = reject;
});
const resultPromise = showSetupDialog<string | null>(root, done => <NewInstallWizard defaultDir={defaultDir} onInstalled={dir => done(dir)} onCancel={() => done(null)} onError={message => rejectWithError(new Error(`Installation failed: ${message}`))} />);
return Promise.race([resultPromise, errorPromise]);
rejectWithError = reject
})
const resultPromise = showSetupDialog<string | null>(root, done => (
<NewInstallWizard
defaultDir={defaultDir}
onInstalled={dir => done(dir)}
onCancel={() => done(null)}
onError={message =>
rejectWithError(new Error(`Installation failed: ${message}`))
}
/>
))
return Promise.race([resultPromise, errorPromise])
}
/**
* Site ~4549: TeleportResumeWrapper (interactive teleport session picker).
* Original callback wiring: onComplete={done}, onCancel={() => done(null)}, source="cliArg".
*/
export async function launchTeleportResumeWrapper(root: Root): Promise<TeleportRemoteResponse | null> {
const {
TeleportResumeWrapper
} = await import('./components/TeleportResumeWrapper.js');
return showSetupDialog<TeleportRemoteResponse | null>(root, done => <TeleportResumeWrapper onComplete={done} onCancel={() => done(null)} source="cliArg" />);
export async function launchTeleportResumeWrapper(
root: Root,
): Promise<TeleportRemoteResponse | null> {
const { TeleportResumeWrapper } = await import(
'./components/TeleportResumeWrapper.js'
)
return showSetupDialog<TeleportRemoteResponse | null>(root, done => (
<TeleportResumeWrapper
onComplete={done}
onCancel={() => done(null)}
source="cliArg"
/>
))
}
/**
* Site ~4597: TeleportRepoMismatchDialog (pick a local checkout of the target repo).
* Original callback wiring: onSelectPath={done}, onCancel={() => done(null)}.
*/
export async function launchTeleportRepoMismatchDialog(root: Root, props: {
targetRepo: string;
initialPaths: string[];
}): Promise<string | null> {
const {
TeleportRepoMismatchDialog
} = await import('./components/TeleportRepoMismatchDialog.js');
return showSetupDialog<string | null>(root, done => <TeleportRepoMismatchDialog targetRepo={props.targetRepo} initialPaths={props.initialPaths} onSelectPath={done} onCancel={() => done(null)} />);
export async function launchTeleportRepoMismatchDialog(
root: Root,
props: {
targetRepo: string
initialPaths: string[]
},
): Promise<string | null> {
const { TeleportRepoMismatchDialog } = await import(
'./components/TeleportRepoMismatchDialog.js'
)
return showSetupDialog<string | null>(root, done => (
<TeleportRepoMismatchDialog
targetRepo={props.targetRepo}
initialPaths={props.initialPaths}
onSelectPath={done}
onCancel={() => done(null)}
/>
))
}
/**
@@ -114,19 +171,31 @@ export async function launchTeleportRepoMismatchDialog(root: Root, props: {
* Uses renderAndRun, NOT showSetupDialog. Wraps in <App><KeybindingSetup>.
* Preserves original Promise.all parallelism between getWorktreePaths and imports.
*/
export async function launchResumeChooser(root: Root, appProps: {
getFpsMetrics: () => FpsMetrics | undefined;
stats: StatsStore;
initialState: AppState;
}, worktreePathsPromise: Promise<string[]>, resumeProps: Omit<ResumeConversationProps, 'worktreePaths'>): Promise<void> {
const [worktreePaths, {
ResumeConversation
}, {
App
}] = await Promise.all([worktreePathsPromise, import('./screens/ResumeConversation.js'), import('./components/App.js')]);
await renderAndRun(root, <App getFpsMetrics={appProps.getFpsMetrics} stats={appProps.stats} initialState={appProps.initialState}>
export async function launchResumeChooser(
root: Root,
appProps: {
getFpsMetrics: () => FpsMetrics | undefined
stats: StatsStore
initialState: AppState
},
worktreePathsPromise: Promise<string[]>,
resumeProps: Omit<ResumeConversationProps, 'worktreePaths'>,
): Promise<void> {
const [worktreePaths, { ResumeConversation }, { App }] = await Promise.all([
worktreePathsPromise,
import('./screens/ResumeConversation.js'),
import('./components/App.js'),
])
await renderAndRun(
root,
<App
getFpsMetrics={appProps.getFpsMetrics}
stats={appProps.stats}
initialState={appProps.initialState}
>
<KeybindingSetup>
<ResumeConversation {...resumeProps} worktreePaths={worktreePaths} />
</KeybindingSetup>
</App>);
</App>,
)
}

View File

@@ -1,17 +1,19 @@
#!/usr/bin/env bun
import { feature } from 'bun:bundle';
import { feature } from 'bun:bundle'
// Bugfix for corepack auto-pinning, which adds yarnpkg to peoples' package.jsons
// eslint-disable-next-line custom-rules/no-top-level-side-effects
process.env.COREPACK_ENABLE_AUTO_PIN = '0';
process.env.COREPACK_ENABLE_AUTO_PIN = '0'
// Set max heap size for child processes in CCR environments (containers have 16GB)
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level, custom-rules/safe-env-boolean-check
if (process.env.CLAUDE_CODE_REMOTE === 'true') {
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level
const existing = process.env.NODE_OPTIONS || '';
const existing = process.env.NODE_OPTIONS || ''
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level
process.env.NODE_OPTIONS = existing ? `${existing} --max-old-space-size=8192` : '--max-old-space-size=8192';
process.env.NODE_OPTIONS = existing
? `${existing} --max-old-space-size=8192`
: '--max-old-space-size=8192'
}
// Harness-science L0 ablation baseline. Inlined here (not init.ts) because
@@ -30,7 +32,7 @@ if (feature('ABLATION_BASELINE') && process.env.CLAUDE_CODE_ABLATION_BASELINE) {
'CLAUDE_CODE_DISABLE_BACKGROUND_TASKS',
]) {
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level
process.env[k] ??= '1';
process.env[k] ??= '1'
}
}
@@ -40,51 +42,64 @@ if (feature('ABLATION_BASELINE') && process.env.CLAUDE_CODE_ABLATION_BASELINE) {
* Fast-path for --version has zero imports beyond this file.
*/
async function main(): Promise<void> {
const args = process.argv.slice(2);
const args = process.argv.slice(2)
// Fast-path for --version/-v: zero module loading needed
if (args.length === 1 && (args[0] === '--version' || args[0] === '-v' || args[0] === '-V')) {
if (
args.length === 1 &&
(args[0] === '--version' || args[0] === '-v' || args[0] === '-V')
) {
// MACRO.VERSION is inlined at build time
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${MACRO.VERSION} (Claude Code)`);
return;
console.log(`${MACRO.VERSION} (Claude Code)`)
return
}
// For all other paths, load the startup profiler
const { profileCheckpoint } = await import('../utils/startupProfiler.js');
profileCheckpoint('cli_entry');
const { profileCheckpoint } = await import('../utils/startupProfiler.js')
profileCheckpoint('cli_entry')
// Fast-path for --dump-system-prompt: output the rendered system prompt and exit.
// Used by prompt sensitivity evals to extract the system prompt at a specific commit.
// Ant-only: eliminated from external builds via feature flag.
if (feature('DUMP_SYSTEM_PROMPT') && args[0] === '--dump-system-prompt') {
profileCheckpoint('cli_dump_system_prompt_path');
const { enableConfigs } = await import('../utils/config.js');
enableConfigs();
const { getMainLoopModel } = await import('../utils/model/model.js');
const modelIdx = args.indexOf('--model');
const model = (modelIdx !== -1 && args[modelIdx + 1]) || getMainLoopModel();
const { getSystemPrompt } = await import('../constants/prompts.js');
const prompt = await getSystemPrompt([], model);
profileCheckpoint('cli_dump_system_prompt_path')
const { enableConfigs } = await import('../utils/config.js')
enableConfigs()
const { getMainLoopModel } = await import('../utils/model/model.js')
const modelIdx = args.indexOf('--model')
const model = (modelIdx !== -1 && args[modelIdx + 1]) || getMainLoopModel()
const { getSystemPrompt } = await import('../constants/prompts.js')
const prompt = await getSystemPrompt([], model)
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(prompt.join('\n'));
return;
console.log(prompt.join('\n'))
return
}
if (process.argv[2] === '--claude-in-chrome-mcp') {
profileCheckpoint('cli_claude_in_chrome_mcp_path');
const { runClaudeInChromeMcpServer } = await import('../utils/claudeInChrome/mcpServer.js');
await runClaudeInChromeMcpServer();
return;
profileCheckpoint('cli_claude_in_chrome_mcp_path')
const { runClaudeInChromeMcpServer } = await import(
'../utils/claudeInChrome/mcpServer.js'
)
await runClaudeInChromeMcpServer()
return
} else if (process.argv[2] === '--chrome-native-host') {
profileCheckpoint('cli_chrome_native_host_path');
const { runChromeNativeHost } = await import('../utils/claudeInChrome/chromeNativeHost.js');
await runChromeNativeHost();
return;
} else if (feature('CHICAGO_MCP') && process.argv[2] === '--computer-use-mcp') {
profileCheckpoint('cli_computer_use_mcp_path');
const { runComputerUseMcpServer } = await import('../utils/computerUse/mcpServer.js');
await runComputerUseMcpServer();
return;
profileCheckpoint('cli_chrome_native_host_path')
const { runChromeNativeHost } = await import(
'../utils/claudeInChrome/chromeNativeHost.js'
)
await runChromeNativeHost()
return
} else if (
feature('CHICAGO_MCP') &&
process.argv[2] === '--computer-use-mcp'
) {
profileCheckpoint('cli_computer_use_mcp_path')
const { runComputerUseMcpServer } = await import(
'../utils/computerUse/mcpServer.js'
)
await runComputerUseMcpServer()
return
}
// Fast-path for `--daemon-worker=<kind>` (internal — supervisor spawns this).
@@ -93,9 +108,9 @@ async function main(): Promise<void> {
// workers are lean. If a worker kind needs configs/auth (assistant will),
// it calls them inside its run() fn.
if (feature('DAEMON') && args[0] === '--daemon-worker') {
const { runDaemonWorker } = await import('../daemon/workerRegistry.js');
await runDaemonWorker(args[1]);
return;
const { runDaemonWorker } = await import('../daemon/workerRegistry.js')
await runDaemonWorker(args[1])
return
}
// Fast-path for `claude remote-control` (also accepts legacy `claude remote` / `claude sync` / `claude bridge`):
@@ -110,51 +125,59 @@ async function main(): Promise<void> {
args[0] === 'sync' ||
args[0] === 'bridge')
) {
profileCheckpoint('cli_bridge_path');
const { enableConfigs } = await import('../utils/config.js');
enableConfigs();
const { getBridgeDisabledReason, checkBridgeMinVersion } = await import('../bridge/bridgeEnabled.js');
const { BRIDGE_LOGIN_ERROR } = await import('../bridge/types.js');
const { bridgeMain } = await import('../bridge/bridgeMain.js');
const { exitWithError } = await import('../utils/process.js');
profileCheckpoint('cli_bridge_path')
const { enableConfigs } = await import('../utils/config.js')
enableConfigs()
const { getBridgeDisabledReason, checkBridgeMinVersion } = await import(
'../bridge/bridgeEnabled.js'
)
const { BRIDGE_LOGIN_ERROR } = await import('../bridge/types.js')
const { bridgeMain } = await import('../bridge/bridgeMain.js')
const { exitWithError } = await import('../utils/process.js')
// Auth check must come before the GrowthBook gate check — without auth,
// GrowthBook has no user context and would return a stale/default false.
// getBridgeDisabledReason awaits GB init, so the returned value is fresh
// (not the stale disk cache), but init still needs auth headers to work.
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js');
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
if (!getClaudeAIOAuthTokens()?.accessToken) {
exitWithError(BRIDGE_LOGIN_ERROR);
exitWithError(BRIDGE_LOGIN_ERROR)
}
const disabledReason = await getBridgeDisabledReason();
const disabledReason = await getBridgeDisabledReason()
if (disabledReason) {
exitWithError(`Error: ${disabledReason}`);
exitWithError(`Error: ${disabledReason}`)
}
const versionError = checkBridgeMinVersion();
const versionError = checkBridgeMinVersion()
if (versionError) {
exitWithError(versionError);
exitWithError(versionError)
}
// Bridge is a remote control feature - check policy limits
const { waitForPolicyLimitsToLoad, isPolicyAllowed } = await import('../services/policyLimits/index.js');
await waitForPolicyLimitsToLoad();
const { waitForPolicyLimitsToLoad, isPolicyAllowed } = await import(
'../services/policyLimits/index.js'
)
await waitForPolicyLimitsToLoad()
if (!isPolicyAllowed('allow_remote_control')) {
exitWithError("Error: Remote Control is disabled by your organization's policy.");
exitWithError(
"Error: Remote Control is disabled by your organization's policy.",
)
}
await bridgeMain(args.slice(1));
return;
await bridgeMain(args.slice(1))
return
}
// Fast-path for `claude daemon [subcommand]`: long-running supervisor.
if (feature('DAEMON') && args[0] === 'daemon') {
profileCheckpoint('cli_daemon_path');
const { enableConfigs } = await import('../utils/config.js');
enableConfigs();
const { initSinks } = await import('../utils/sinks.js');
initSinks();
const { daemonMain } = await import('../daemon/main.js');
await daemonMain(args.slice(1));
return;
profileCheckpoint('cli_daemon_path')
const { enableConfigs } = await import('../utils/config.js')
enableConfigs()
const { initSinks } = await import('../utils/sinks.js')
initSinks()
const { daemonMain } = await import('../daemon/main.js')
await daemonMain(args.slice(1))
return
}
// Fast-path for `claude ps|logs|attach|kill` and `--bg`/`--background`.
@@ -169,103 +192,117 @@ async function main(): Promise<void> {
args.includes('--bg') ||
args.includes('--background'))
) {
profileCheckpoint('cli_bg_path');
const { enableConfigs } = await import('../utils/config.js');
enableConfigs();
const bg = await import('../cli/bg.js');
profileCheckpoint('cli_bg_path')
const { enableConfigs } = await import('../utils/config.js')
enableConfigs()
const bg = await import('../cli/bg.js')
switch (args[0]) {
case 'ps':
await bg.psHandler(args.slice(1));
break;
await bg.psHandler(args.slice(1))
break
case 'logs':
await bg.logsHandler(args[1]);
break;
await bg.logsHandler(args[1])
break
case 'attach':
await bg.attachHandler(args[1]);
break;
await bg.attachHandler(args[1])
break
case 'kill':
await bg.killHandler(args[1]);
break;
await bg.killHandler(args[1])
break
default:
await bg.handleBgFlag(args);
await bg.handleBgFlag(args)
}
return;
return
}
// Fast-path for template job commands.
if (feature('TEMPLATES') && (args[0] === 'new' || args[0] === 'list' || args[0] === 'reply')) {
profileCheckpoint('cli_templates_path');
const { templatesMain } = await import('../cli/handlers/templateJobs.js');
await templatesMain(args);
if (
feature('TEMPLATES') &&
(args[0] === 'new' || args[0] === 'list' || args[0] === 'reply')
) {
profileCheckpoint('cli_templates_path')
const { templatesMain } = await import('../cli/handlers/templateJobs.js')
await templatesMain(args)
// process.exit (not return) — mountFleetView's Ink TUI can leave event
// loop handles that prevent natural exit.
// eslint-disable-next-line custom-rules/no-process-exit
process.exit(0);
process.exit(0)
}
// Fast-path for `claude environment-runner`: headless BYOC runner.
// feature() must stay inline for build-time dead code elimination.
if (feature('BYOC_ENVIRONMENT_RUNNER') && args[0] === 'environment-runner') {
profileCheckpoint('cli_environment_runner_path');
const { environmentRunnerMain } = await import('../environment-runner/main.js');
await environmentRunnerMain(args.slice(1));
return;
profileCheckpoint('cli_environment_runner_path')
const { environmentRunnerMain } = await import(
'../environment-runner/main.js'
)
await environmentRunnerMain(args.slice(1))
return
}
// Fast-path for `claude self-hosted-runner`: headless self-hosted-runner
// targeting the SelfHostedRunnerWorkerService API (register + poll; poll IS
// heartbeat). feature() must stay inline for build-time dead code elimination.
if (feature('SELF_HOSTED_RUNNER') && args[0] === 'self-hosted-runner') {
profileCheckpoint('cli_self_hosted_runner_path');
const { selfHostedRunnerMain } = await import('../self-hosted-runner/main.js');
await selfHostedRunnerMain(args.slice(1));
return;
profileCheckpoint('cli_self_hosted_runner_path')
const { selfHostedRunnerMain } = await import(
'../self-hosted-runner/main.js'
)
await selfHostedRunnerMain(args.slice(1))
return
}
// Fast-path for --worktree --tmux: exec into tmux before loading full CLI
const hasTmuxFlag = args.includes('--tmux') || args.includes('--tmux=classic');
const hasTmuxFlag = args.includes('--tmux') || args.includes('--tmux=classic')
if (
hasTmuxFlag &&
(args.includes('-w') || args.includes('--worktree') || args.some(a => a.startsWith('--worktree=')))
(args.includes('-w') ||
args.includes('--worktree') ||
args.some(a => a.startsWith('--worktree=')))
) {
profileCheckpoint('cli_tmux_worktree_fast_path');
const { enableConfigs } = await import('../utils/config.js');
enableConfigs();
const { isWorktreeModeEnabled } = await import('../utils/worktreeModeEnabled.js');
profileCheckpoint('cli_tmux_worktree_fast_path')
const { enableConfigs } = await import('../utils/config.js')
enableConfigs()
const { isWorktreeModeEnabled } = await import(
'../utils/worktreeModeEnabled.js'
)
if (isWorktreeModeEnabled()) {
const { execIntoTmuxWorktree } = await import('../utils/worktree.js');
const result = await execIntoTmuxWorktree(args);
const { execIntoTmuxWorktree } = await import('../utils/worktree.js')
const result = await execIntoTmuxWorktree(args)
if (result.handled) {
return;
return
}
// If not handled (e.g., error), fall through to normal CLI
if (result.error) {
const { exitWithError } = await import('../utils/process.js');
exitWithError(result.error);
const { exitWithError } = await import('../utils/process.js')
exitWithError(result.error)
}
}
}
// Redirect common update flag mistakes to the update subcommand
if (args.length === 1 && (args[0] === '--update' || args[0] === '--upgrade')) {
process.argv = [process.argv[0]!, process.argv[1]!, 'update'];
if (
args.length === 1 &&
(args[0] === '--update' || args[0] === '--upgrade')
) {
process.argv = [process.argv[0]!, process.argv[1]!, 'update']
}
// --bare: set SIMPLE early so gates fire during module eval / commander
// option building (not just inside the action handler).
if (args.includes('--bare')) {
process.env.CLAUDE_CODE_SIMPLE = '1';
process.env.CLAUDE_CODE_SIMPLE = '1'
}
// No special flags detected, load and run the full CLI
const { startCapturingEarlyInput } = await import('../utils/earlyInput.js');
startCapturingEarlyInput();
profileCheckpoint('cli_before_main_import');
const { main: cliMain } = await import('../main.jsx');
profileCheckpoint('cli_after_main_import');
await cliMain();
profileCheckpoint('cli_after_main_complete');
const { startCapturingEarlyInput } = await import('../utils/earlyInput.js')
startCapturingEarlyInput()
profileCheckpoint('cli_before_main_import')
const { main: cliMain } = await import('../main.jsx')
profileCheckpoint('cli_after_main_import')
await cliMain()
profileCheckpoint('cli_after_main_complete')
}
// eslint-disable-next-line custom-rules/no-top-level-side-effects
void main();
void main()

View File

@@ -1,46 +1,76 @@
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';
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
}));
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))
})
}
/**
@@ -49,11 +79,12 @@ export function showDialog<T = void>(root: Root, renderer: (done: (result: T) =>
* 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 })
}
/**
@@ -62,64 +93,93 @@ export async function exitWithError(root: Root, message: string, beforeExit?: ()
* 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?.();
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);
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}>
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>);
</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 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
export async function showSetupScreens(
root: Root,
permissionMode: PermissionMode,
allowDangerouslySkipPermissions: boolean,
commands?: Command[],
claudeInChrome?: boolean,
devChannels?: ChannelEntry[],
): Promise<boolean> {
if (
"production" === 'test' ||
isEnvTruthy(false) ||
process.env.IS_DEMO // Skip onboarding in demo mode
) {
return false;
return false
}
const config = getGlobalConfig();
let onboardingShown = false;
if (!config.theme || !config.hasCompletedOnboarding // always show onboarding at least once
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
});
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.
@@ -133,70 +193,83 @@ export async function showSetupScreens(root: Root, permissionMode: PermissionMod
// 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');
await showSetupDialog(root, done => <ClaudeMdExternalIncludesDialog onDone={done} isStandaloneDialog externalIncludes={externalIncludes} />);
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();
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 decision = await showSetupDialog<string>(root, done => <GroveDialog showIfAlreadyViewed={false} location={onboardingShown ? 'onboarding' : 'policy_update_modal'} onDone={done} />);
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
}
}
@@ -204,33 +277,54 @@ export async function showSetupScreens(root: Root, permissionMode: PermissionMod
// 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');
await showSetupDialog<boolean>(root, done => <ApproveApiKey customApiKeyTruncated={customApiKeyTruncated} onDone={done} />, {
onChangeAppState
});
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 (
(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 />);
const { AutoModeOptInDialog } = await import(
'./components/AutoModeOptInDialog.js'
)
await showSetupDialog(root, done => (
<AutoModeOptInDialog
onAccept={done}
onDecline={() => gracefulShutdownSync(1)}
declineExits
/>
))
}
}
@@ -248,14 +342,15 @@ export async function showSetupScreens(root: Root, permissionMode: PermissionMod
// 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');
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')]);
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
@@ -264,102 +359,115 @@ export async function showSetupScreens(root: Root, permissionMode: PermissionMod
// (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);
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();
}} />);
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} />);
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
// 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-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);
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>);
reason: flicker.reason,
} as unknown as Record<string, boolean | number | undefined>)
}
lastFlickerTime = now;
lastFlickerTime = now
}
}
}
};
},
},
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,21 +5,22 @@
// would resolve to scripts/external-stubs/src/types/ (doesn't exist).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type M = any;
type M = any
export function useMoreRight(_args: {
enabled: boolean;
setMessages: (action: M[] | ((prev: M[]) => M[])) => void;
inputValue: string;
setInputValue: (s: string) => void;
setToolJSX: (args: M) => void;
enabled: boolean
setMessages: (action: M[] | ((prev: M[]) => M[])) => void
inputValue: string
setInputValue: (s: string) => void
setToolJSX: (args: M) => void
}): {
onBeforeQuery: (input: string, all: M[], n: number) => Promise<boolean>;
onTurnComplete: (all: M[], aborted: boolean) => Promise<void>;
render: () => null;
onBeforeQuery: (input: string, all: M[], n: number) => Promise<boolean>
onTurnComplete: (all: M[], aborted: boolean) => Promise<void>
render: () => null
} {
return {
onBeforeQuery: async () => true,
onTurnComplete: async () => {},
render: () => null
};
render: () => null,
}
}

View File

@@ -1,22 +1,28 @@
import React from 'react';
import type { StatsStore } from './context/stats.js';
import type { Root } from './ink.js';
import type { Props as REPLProps } from './screens/REPL.js';
import type { AppState } from './state/AppStateStore.js';
import type { FpsMetrics } from './utils/fpsTracker.js';
import React from 'react'
import type { StatsStore } from './context/stats.js'
import type { Root } from './ink.js'
import type { Props as REPLProps } from './screens/REPL.js'
import type { AppState } from './state/AppStateStore.js'
import type { FpsMetrics } from './utils/fpsTracker.js'
type AppWrapperProps = {
getFpsMetrics: () => FpsMetrics | undefined;
stats?: StatsStore;
initialState: AppState;
};
export async function launchRepl(root: Root, appProps: AppWrapperProps, replProps: REPLProps, renderAndRun: (root: Root, element: React.ReactNode) => Promise<void>): Promise<void> {
const {
App
} = await import('./components/App.js');
const {
REPL
} = await import('./screens/REPL.js');
await renderAndRun(root, <App {...appProps}>
<REPL {...replProps} />
</App>);
getFpsMetrics: () => FpsMetrics | undefined
stats?: StatsStore
initialState: AppState
}
export async function launchRepl(
root: Root,
appProps: AppWrapperProps,
replProps: REPLProps,
renderAndRun: (root: Root, element: React.ReactNode) => Promise<void>,
): Promise<void> {
const { App } = await import('./components/App.js')
const { REPL } = await import('./screens/REPL.js')
await renderAndRun(
root,
<App {...appProps}>
<REPL {...replProps} />
</App>,
)
}

View File

@@ -1,96 +1,72 @@
import { c as _c } from "react/compiler-runtime";
import * as React from 'react';
import { useEffect, useRef } from 'react';
import { KeyboardShortcutHint } from '../components/design-system/KeyboardShortcutHint.js';
import { Box, Text } from '../ink.js';
import { useKeybinding } from '../keybindings/useKeybinding.js';
import * as React from 'react'
import { useEffect, useRef } from 'react'
import { KeyboardShortcutHint } from '../components/design-system/KeyboardShortcutHint.js'
import { Box, Text } from '../ink.js'
import { useKeybinding } from '../keybindings/useKeybinding.js'
type Props = {
onRun: () => void;
onCancel: () => void;
reason: string;
};
onRun: () => void
onCancel: () => void
reason: string
}
/**
* Component that shows a notification about running /issue command
* with the ability to cancel via ESC key
*/
export function AutoRunIssueNotification(t0) {
const $ = _c(8);
const {
onRun,
onCancel,
reason
} = t0;
const hasRunRef = useRef(false);
let t1;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t1 = {
context: "Confirmation"
};
$[0] = t1;
} else {
t1 = $[0];
}
useKeybinding("confirm:no", onCancel, t1);
let t2;
let t3;
if ($[1] !== onRun) {
t2 = () => {
if (!hasRunRef.current) {
hasRunRef.current = true;
onRun();
}
};
t3 = [onRun];
$[1] = onRun;
$[2] = t2;
$[3] = t3;
} else {
t2 = $[2];
t3 = $[3];
}
useEffect(t2, t3);
let t4;
if ($[4] === Symbol.for("react.memo_cache_sentinel")) {
t4 = <Box><Text bold={true}>Running feedback capture...</Text></Box>;
$[4] = t4;
} else {
t4 = $[4];
}
let t5;
if ($[5] === Symbol.for("react.memo_cache_sentinel")) {
t5 = <Box><Text dimColor={true}>Press <KeyboardShortcutHint shortcut="Esc" action="cancel" /> anytime</Text></Box>;
$[5] = t5;
} else {
t5 = $[5];
}
let t6;
if ($[6] !== reason) {
t6 = <Box flexDirection="column" marginTop={1}>{t4}{t5}<Box><Text dimColor={true}>Reason: {reason}</Text></Box></Box>;
$[6] = reason;
$[7] = t6;
} else {
t6 = $[7];
}
return t6;
export function AutoRunIssueNotification({
onRun,
onCancel,
reason,
}: Props): React.ReactNode {
const hasRunRef = useRef(false)
// Handle ESC key to cancel
useKeybinding('confirm:no', onCancel, { context: 'Confirmation' })
// Run /issue immediately on mount
useEffect(() => {
if (!hasRunRef.current) {
hasRunRef.current = true
onRun()
}
}, [onRun])
return (
<Box flexDirection="column" marginTop={1}>
<Box>
<Text bold>Running feedback capture...</Text>
</Box>
<Box>
<Text dimColor>
Press <KeyboardShortcutHint shortcut="Esc" action="cancel" /> anytime
</Text>
</Box>
<Box>
<Text dimColor>Reason: {reason}</Text>
</Box>
</Box>
)
}
export type AutoRunIssueReason = 'feedback_survey_bad' | 'feedback_survey_good';
export type AutoRunIssueReason = 'feedback_survey_bad' | 'feedback_survey_good'
/**
* Determines if /issue should auto-run for Ant users
*/
export function shouldAutoRunIssue(reason: AutoRunIssueReason): boolean {
// Only for Ant users
if ((process.env.USER_TYPE) !== 'ant') {
return false;
if (process.env.USER_TYPE !== 'ant') {
return false
}
switch (reason) {
case 'feedback_survey_bad':
return false;
return false
case 'feedback_survey_good':
return false;
return false
default:
return false;
return false
}
}
@@ -100,10 +76,10 @@ export function shouldAutoRunIssue(reason: AutoRunIssueReason): boolean {
*/
export function getAutoRunCommand(reason: AutoRunIssueReason): string {
// Only ant builds have the /good-claude command
if ((process.env.USER_TYPE) === 'ant' && reason === 'feedback_survey_good') {
return '/good-claude';
if (process.env.USER_TYPE === 'ant' && reason === 'feedback_survey_good') {
return '/good-claude'
}
return '/issue';
return '/issue'
}
/**
@@ -112,10 +88,10 @@ export function getAutoRunCommand(reason: AutoRunIssueReason): string {
export function getAutoRunIssueReasonText(reason: AutoRunIssueReason): string {
switch (reason) {
case 'feedback_survey_bad':
return 'You responded "Bad" to the feedback survey';
return 'You responded "Bad" to the feedback survey'
case 'feedback_survey_good':
return 'You responded "Good" to the feedback survey';
return 'You responded "Good" to the feedback survey'
default:
return 'Unknown reason';
return 'Unknown reason'
}
}

View File

@@ -1,104 +1,146 @@
import * as React from 'react';
import { MessageResponse } from '../../components/MessageResponse.js';
import { supportsHyperlinks } from '../../ink/supports-hyperlinks.js';
import { Link, Text } from '../../ink.js';
import { renderToolResultMessage as renderDefaultMCPToolResultMessage } from '../../tools/MCPTool/UI.js';
import type { MCPToolResult } from '../../utils/mcpValidation.js';
import { truncateToWidth } from '../format.js';
import { trackClaudeInChromeTabId } from './common.js';
export type { Tool } from '@modelcontextprotocol/sdk/types.js';
import * as React from 'react'
import { MessageResponse } from '../../components/MessageResponse.js'
import { supportsHyperlinks } from '../../ink/supports-hyperlinks.js'
import { Link, Text } from '../../ink.js'
import { renderToolResultMessage as renderDefaultMCPToolResultMessage } from '../../tools/MCPTool/UI.js'
import type { MCPToolResult } from '../../utils/mcpValidation.js'
import { truncateToWidth } from '../format.js'
import { trackClaudeInChromeTabId } from './common.js'
export type { Tool } from '@modelcontextprotocol/sdk/types.js'
/**
* All tool names from BROWSER_TOOLS in @ant/claude-for-chrome-mcp.
* Keep in sync with the package's BROWSER_TOOLS array.
*/
export type ChromeToolName = 'javascript_tool' | 'read_page' | 'find' | 'form_input' | 'computer' | 'navigate' | 'resize_window' | 'gif_creator' | 'upload_image' | 'get_page_text' | 'tabs_context_mcp' | 'tabs_create_mcp' | 'update_plan' | 'read_console_messages' | 'read_network_requests' | 'shortcuts_list' | 'shortcuts_execute';
const CHROME_EXTENSION_FOCUS_TAB_URL_BASE = 'https://clau.de/chrome/tab/';
function renderChromeToolUseMessage(input: Record<string, unknown>, toolName: ChromeToolName, verbose: boolean): React.ReactNode {
const tabId = input.tabId;
export type ChromeToolName =
| 'javascript_tool'
| 'read_page'
| 'find'
| 'form_input'
| 'computer'
| 'navigate'
| 'resize_window'
| 'gif_creator'
| 'upload_image'
| 'get_page_text'
| 'tabs_context_mcp'
| 'tabs_create_mcp'
| 'update_plan'
| 'read_console_messages'
| 'read_network_requests'
| 'shortcuts_list'
| 'shortcuts_execute'
const CHROME_EXTENSION_FOCUS_TAB_URL_BASE = 'https://clau.de/chrome/tab/'
function renderChromeToolUseMessage(
input: Record<string, unknown>,
toolName: ChromeToolName,
verbose: boolean,
): React.ReactNode {
const tabId = input.tabId
if (typeof tabId === 'number') {
trackClaudeInChromeTabId(tabId);
trackClaudeInChromeTabId(tabId)
}
// Build secondary info based on tool type and input
const secondaryInfo: string[] = [];
const secondaryInfo: string[] = []
switch (toolName) {
case 'navigate':
if (typeof input.url === 'string') {
try {
const url = new URL(input.url);
secondaryInfo.push(url.hostname);
const url = new URL(input.url)
secondaryInfo.push(url.hostname)
} catch {
secondaryInfo.push(truncateToWidth(input.url, 30));
secondaryInfo.push(truncateToWidth(input.url, 30))
}
}
break;
break
case 'find':
if (typeof input.query === 'string') {
secondaryInfo.push(`pattern: ${truncateToWidth(input.query, 30)}`);
secondaryInfo.push(`pattern: ${truncateToWidth(input.query, 30)}`)
}
break;
break
case 'computer':
if (typeof input.action === 'string') {
const action = input.action;
if (action === 'left_click' || action === 'right_click' || action === 'double_click' || action === 'middle_click') {
const action = input.action
if (
action === 'left_click' ||
action === 'right_click' ||
action === 'double_click' ||
action === 'middle_click'
) {
if (typeof input.ref === 'string') {
secondaryInfo.push(`${action} on ${input.ref}`);
secondaryInfo.push(`${action} on ${input.ref}`)
} else if (Array.isArray(input.coordinate)) {
secondaryInfo.push(`${action} at (${input.coordinate.join(', ')})`);
secondaryInfo.push(`${action} at (${input.coordinate.join(', ')})`)
} else {
secondaryInfo.push(action);
secondaryInfo.push(action)
}
} else if (action === 'type' && typeof input.text === 'string') {
secondaryInfo.push(`type "${truncateToWidth(input.text, 15)}"`);
secondaryInfo.push(`type "${truncateToWidth(input.text, 15)}"`)
} else if (action === 'key' && typeof input.text === 'string') {
secondaryInfo.push(`key ${input.text}`);
} else if (action === 'scroll' && typeof input.scroll_direction === 'string') {
secondaryInfo.push(`scroll ${input.scroll_direction}`);
secondaryInfo.push(`key ${input.text}`)
} else if (
action === 'scroll' &&
typeof input.scroll_direction === 'string'
) {
secondaryInfo.push(`scroll ${input.scroll_direction}`)
} else if (action === 'wait' && typeof input.duration === 'number') {
secondaryInfo.push(`wait ${input.duration}s`);
secondaryInfo.push(`wait ${input.duration}s`)
} else if (action === 'left_click_drag') {
secondaryInfo.push('drag');
secondaryInfo.push('drag')
} else {
secondaryInfo.push(action);
secondaryInfo.push(action)
}
}
break;
break
case 'gif_creator':
if (typeof input.action === 'string') {
secondaryInfo.push(`${input.action}`);
secondaryInfo.push(`${input.action}`)
}
break;
break
case 'resize_window':
if (typeof input.width === 'number' && typeof input.height === 'number') {
secondaryInfo.push(`${input.width}x${input.height}`);
secondaryInfo.push(`${input.width}x${input.height}`)
}
break;
break
case 'read_console_messages':
if (typeof input.pattern === 'string') {
secondaryInfo.push(`pattern: ${truncateToWidth(input.pattern, 20)}`);
secondaryInfo.push(`pattern: ${truncateToWidth(input.pattern, 20)}`)
}
if (input.onlyErrors === true) {
secondaryInfo.push('errors only');
secondaryInfo.push('errors only')
}
break;
break
case 'read_network_requests':
if (typeof input.urlPattern === 'string') {
secondaryInfo.push(`pattern: ${truncateToWidth(input.urlPattern, 20)}`);
secondaryInfo.push(`pattern: ${truncateToWidth(input.urlPattern, 20)}`)
}
break;
break
case 'shortcuts_execute':
if (typeof input.shortcutId === 'string') {
secondaryInfo.push(`shortcut_id: ${input.shortcutId}`);
secondaryInfo.push(`shortcut_id: ${input.shortcutId}`)
}
break;
break
case 'javascript_tool':
// In verbose mode, show the full code
if (verbose && typeof input.text === 'string') {
return input.text;
return input.text
}
// In non-verbose mode, return empty string to preserve View Tab layout
return '';
return ''
case 'tabs_create_mcp':
case 'tabs_context_mcp':
case 'form_input':
@@ -109,9 +151,10 @@ function renderChromeToolUseMessage(input: Record<string, unknown>, toolName: Ch
case 'update_plan':
// These tools don't have meaningful secondary info to show inline.
// Return empty string (not null) to ensure tool header still renders.
return '';
return ''
}
return secondaryInfo.join(', ') || null;
return secondaryInfo.join(', ') || null
}
/**
@@ -123,22 +166,29 @@ function renderChromeToolUseMessage(input: Record<string, unknown>, toolName: Ch
*/
function renderChromeViewTabLink(input: unknown): React.ReactNode {
if (!supportsHyperlinks()) {
return null;
return null
}
if (typeof input !== 'object' || input === null || !('tabId' in input)) {
return null;
return null
}
const tabId = typeof input.tabId === 'number' ? input.tabId : typeof input.tabId === 'string' ? parseInt(input.tabId, 10) : NaN;
const tabId =
typeof input.tabId === 'number'
? input.tabId
: typeof input.tabId === 'string'
? parseInt(input.tabId, 10)
: NaN
if (isNaN(tabId)) {
return null;
return null
}
const linkUrl = `${CHROME_EXTENSION_FOCUS_TAB_URL_BASE}${tabId}`;
return <Text>
const linkUrl = `${CHROME_EXTENSION_FOCUS_TAB_URL_BASE}${tabId}`
return (
<Text>
{' '}
<Link url={linkUrl}>
<Text color="subtle">[View Tab]</Text>
</Link>
</Text>;
</Text>
)
}
/**
@@ -146,72 +196,79 @@ function renderChromeViewTabLink(input: unknown): React.ReactNode {
* Shows a brief summary for successful results. Errors are handled by
* the default renderToolUseErrorMessage when is_error is set.
*/
export function renderChromeToolResultMessage(output: MCPToolResult, toolName: ChromeToolName, verbose: boolean): React.ReactNode {
export function renderChromeToolResultMessage(
output: MCPToolResult,
toolName: ChromeToolName,
verbose: boolean,
): React.ReactNode {
if (verbose) {
return renderDefaultMCPToolResultMessage(output, [], {
verbose
});
return renderDefaultMCPToolResultMessage(output, [], { verbose })
}
let summary: string | null = null;
let summary: string | null = null
switch (toolName) {
case 'navigate':
summary = 'Navigation completed';
break;
summary = 'Navigation completed'
break
case 'tabs_create_mcp':
summary = 'Tab created';
break;
summary = 'Tab created'
break
case 'tabs_context_mcp':
summary = 'Tabs read';
break;
summary = 'Tabs read'
break
case 'form_input':
summary = 'Input completed';
break;
summary = 'Input completed'
break
case 'computer':
summary = 'Action completed';
break;
summary = 'Action completed'
break
case 'resize_window':
summary = 'Window resized';
break;
summary = 'Window resized'
break
case 'find':
summary = 'Search completed';
break;
summary = 'Search completed'
break
case 'gif_creator':
summary = 'GIF action completed';
break;
summary = 'GIF action completed'
break
case 'read_console_messages':
summary = 'Console messages retrieved';
break;
summary = 'Console messages retrieved'
break
case 'read_network_requests':
summary = 'Network requests retrieved';
break;
summary = 'Network requests retrieved'
break
case 'shortcuts_list':
summary = 'Shortcuts retrieved';
break;
summary = 'Shortcuts retrieved'
break
case 'shortcuts_execute':
summary = 'Shortcut executed';
break;
summary = 'Shortcut executed'
break
case 'javascript_tool':
summary = 'Script executed';
break;
summary = 'Script executed'
break
case 'read_page':
summary = 'Page read';
break;
summary = 'Page read'
break
case 'upload_image':
summary = 'Image uploaded';
break;
summary = 'Image uploaded'
break
case 'get_page_text':
summary = 'Page text retrieved';
break;
summary = 'Page text retrieved'
break
case 'update_plan':
summary = 'Plan updated';
break;
summary = 'Plan updated'
break
}
if (summary) {
return <MessageResponse height={1}>
return (
<MessageResponse height={1}>
<Text dimColor>{summary}</Text>
</MessageResponse>;
</MessageResponse>
)
}
return null;
return null
}
/**
@@ -219,43 +276,56 @@ export function renderChromeToolResultMessage(output: MCPToolResult, toolName: C
* rendering for chrome tools in a single spread operation.
*/
export function getClaudeInChromeMCPToolOverrides(toolName: string): {
userFacingName: (input?: Record<string, unknown>) => string;
renderToolUseMessage: (input: Record<string, unknown>, options: {
verbose: boolean;
}) => React.ReactNode;
renderToolUseTag: (input: Partial<Record<string, unknown>>) => React.ReactNode;
renderToolResultMessage: (output: string | MCPToolResult, progressMessagesForMessage: unknown[], options: {
verbose: boolean;
}) => React.ReactNode;
userFacingName: (input?: Record<string, unknown>) => string
renderToolUseMessage: (
input: Record<string, unknown>,
options: { verbose: boolean },
) => React.ReactNode
renderToolUseTag: (input: Partial<Record<string, unknown>>) => React.ReactNode
renderToolResultMessage: (
output: string | MCPToolResult,
progressMessagesForMessage: unknown[],
options: { verbose: boolean },
) => React.ReactNode
} {
return {
userFacingName(_input?: Record<string, unknown>) {
// Trim the _mcp postfix that show up in some of the tool names
const displayName = toolName.replace(/_mcp$/, '');
return `Claude in Chrome[${displayName}]`;
const displayName = toolName.replace(/_mcp$/, '')
return `Claude in Chrome[${displayName}]`
},
renderToolUseMessage(input: Record<string, unknown>, {
verbose
}: {
verbose: boolean;
}): React.ReactNode {
return renderChromeToolUseMessage(input, toolName as ChromeToolName, verbose);
renderToolUseMessage(
input: Record<string, unknown>,
{ verbose }: { verbose: boolean },
): React.ReactNode {
return renderChromeToolUseMessage(
input,
toolName as ChromeToolName,
verbose,
)
},
renderToolUseTag(input: Partial<Record<string, unknown>>): React.ReactNode {
return renderChromeViewTabLink(input);
return renderChromeViewTabLink(input)
},
renderToolResultMessage(output: string | MCPToolResult, _progressMessagesForMessage: unknown[], {
verbose
}: {
verbose: boolean;
}): React.ReactNode {
renderToolResultMessage(
output: string | MCPToolResult,
_progressMessagesForMessage: unknown[],
{ verbose }: { verbose: boolean },
): React.ReactNode {
if (!isMCPToolResult(output)) {
return null;
return null
}
return renderChromeToolResultMessage(output, toolName as ChromeToolName, verbose);
}
};
return renderChromeToolResultMessage(
output,
toolName as ChromeToolName,
verbose,
)
},
}
}
function isMCPToolResult(output: string | MCPToolResult): output is MCPToolResult {
return typeof output === 'object' && output !== null;
function isMCPToolResult(
output: string | MCPToolResult,
): output is MCPToolResult {
return typeof output === 'object' && output !== null
}

View File

@@ -1,23 +1,24 @@
import * as React from 'react';
import { MessageResponse } from '../../components/MessageResponse.js';
import { Text } from '../../ink.js';
import { truncateToWidth } from '../format.js';
import type { MCPToolResult } from '../mcpValidation.js';
import * as React from 'react'
import { MessageResponse } from '../../components/MessageResponse.js'
import { Text } from '../../ink.js'
import { truncateToWidth } from '../format.js'
import type { MCPToolResult } from '../mcpValidation.js'
type CuToolInput = Record<string, unknown> & {
coordinate?: [number, number];
start_coordinate?: [number, number];
text?: string;
apps?: Array<{
displayName?: string;
}>;
region?: [number, number, number, number];
direction?: string;
amount?: number;
duration?: number;
};
function fmtCoord(c: [number, number] | undefined): string {
return c ? `(${c[0]}, ${c[1]})` : '';
coordinate?: [number, number]
start_coordinate?: [number, number]
text?: string
apps?: Array<{ displayName?: string }>
region?: [number, number, number, number]
direction?: string
amount?: number
duration?: number
}
function fmtCoord(c: [number, number] | undefined): string {
return c ? `(${c[0]}, ${c[1]})` : ''
}
const RESULT_SUMMARY: Readonly<Partial<Record<string, string>>> = {
screenshot: 'Captured',
zoom: 'Captured',
@@ -32,8 +33,8 @@ const RESULT_SUMMARY: Readonly<Partial<Record<string, string>>> = {
hold_key: 'Pressed',
scroll: 'Scrolled',
left_click_drag: 'Dragged',
open_application: 'Opened'
};
open_application: 'Opened',
}
/**
* Rendering overrides for `mcp__computer-use__*` tools. Spread into the MCP
@@ -41,18 +42,22 @@ const RESULT_SUMMARY: Readonly<Partial<Record<string, string>>> = {
* Mirror of `getClaudeInChromeMCPToolOverrides`.
*/
export function getComputerUseMCPRenderingOverrides(toolName: string): {
userFacingName: () => string;
renderToolUseMessage: (input: Record<string, unknown>, options: {
verbose: boolean;
}) => React.ReactNode;
renderToolResultMessage: (output: MCPToolResult, progressMessages: unknown[], options: {
verbose: boolean;
}) => React.ReactNode;
userFacingName: () => string
renderToolUseMessage: (
input: Record<string, unknown>,
options: { verbose: boolean },
) => React.ReactNode
renderToolResultMessage: (
output: MCPToolResult,
progressMessages: unknown[],
options: { verbose: boolean },
) => React.ReactNode
} {
return {
userFacingName() {
return `Computer Use[${toolName}]`;
return `Computer Use[${toolName}]`
},
// AssistantToolUseMessage.tsx contract: null hides the ENTIRE row, '' shows
// the tool name without "(args)". Every path below returns '' when there's
// nothing to show — never null.
@@ -64,61 +69,89 @@ export function getComputerUseMCPRenderingOverrides(toolName: string): {
case 'cursor_position':
case 'list_granted_applications':
case 'read_clipboard':
return '';
return ''
case 'left_click':
case 'right_click':
case 'middle_click':
case 'double_click':
case 'triple_click':
case 'mouse_move':
return fmtCoord(input.coordinate);
return fmtCoord(input.coordinate)
case 'left_click_drag':
return input.start_coordinate ? `${fmtCoord(input.start_coordinate)}${fmtCoord(input.coordinate)}` : `to ${fmtCoord(input.coordinate)}`;
return input.start_coordinate
? `${fmtCoord(input.start_coordinate)}${fmtCoord(input.coordinate)}`
: `to ${fmtCoord(input.coordinate)}`
case 'type':
return typeof input.text === 'string' ? `"${truncateToWidth(input.text, 40)}"` : '';
return typeof input.text === 'string'
? `"${truncateToWidth(input.text, 40)}"`
: ''
case 'key':
case 'hold_key':
return typeof input.text === 'string' ? input.text : '';
return typeof input.text === 'string' ? input.text : ''
case 'scroll':
return [input.direction, input.amount && `×${input.amount}`, input.coordinate && `at ${fmtCoord(input.coordinate)}`].filter(Boolean).join(' ');
case 'zoom':
{
const r = input.region;
return Array.isArray(r) && r.length === 4 ? `[${r[0]}, ${r[1]}, ${r[2]}, ${r[3]}]` : '';
}
return [
input.direction,
input.amount && `×${input.amount}`,
input.coordinate && `at ${fmtCoord(input.coordinate)}`,
]
.filter(Boolean)
.join(' ')
case 'zoom': {
const r = input.region
return Array.isArray(r) && r.length === 4
? `[${r[0]}, ${r[1]}, ${r[2]}, ${r[3]}]`
: ''
}
case 'wait':
return typeof input.duration === 'number' ? `${input.duration}s` : '';
return typeof input.duration === 'number' ? `${input.duration}s` : ''
case 'write_clipboard':
return typeof input.text === 'string' ? `"${truncateToWidth(input.text, 40)}"` : '';
return typeof input.text === 'string'
? `"${truncateToWidth(input.text, 40)}"`
: ''
case 'open_application':
return typeof input.bundle_id === 'string' ? String(input.bundle_id) : '';
case 'request_access':
{
const apps = input.apps;
if (!Array.isArray(apps)) return '';
const names = apps.map(a => typeof a?.displayName === 'string' ? a.displayName : '').filter(Boolean);
return names.join(', ');
}
case 'computer_batch':
{
const actions = input.actions;
return Array.isArray(actions) ? `${actions.length} actions` : '';
}
return typeof input.bundle_id === 'string'
? String(input.bundle_id)
: ''
case 'request_access': {
const apps = input.apps
if (!Array.isArray(apps)) return ''
const names = apps
.map(a => (typeof a?.displayName === 'string' ? a.displayName : ''))
.filter(Boolean)
return names.join(', ')
}
case 'computer_batch': {
const actions = input.actions
return Array.isArray(actions) ? `${actions.length} actions` : ''
}
default:
return '';
return ''
}
},
renderToolResultMessage(output, _progress, {
verbose
}) {
if (verbose || typeof output !== 'object' || output === null) return null;
renderToolResultMessage(output, _progress, { verbose }) {
if (verbose || typeof output !== 'object' || output === null) return null
// Non-verbose: one-line dim summary, like Chrome's pattern.
const summary = RESULT_SUMMARY[toolName];
if (!summary) return null;
return <MessageResponse height={1}>
const summary = RESULT_SUMMARY[toolName]
if (!summary) return null
return (
<MessageResponse height={1}>
<Text dimColor>{summary}</Text>
</MessageResponse>;
}
};
</MessageResponse>
)
},
}
}

View File

@@ -16,22 +16,35 @@
* GrowthBook gate `tengu_malort_pedway` (see gates.ts).
*/
import { bindSessionContext, type ComputerUseSessionContext, type CuCallToolResult, type CuPermissionRequest, type CuPermissionResponse, DEFAULT_GRANT_FLAGS, type ScreenshotDims } from '@ant/computer-use-mcp';
import * as React from 'react';
import { getSessionId } from '../../bootstrap/state.js';
import { ComputerUseApproval } from '../../components/permissions/ComputerUseApproval/ComputerUseApproval.js';
import type { Tool, ToolUseContext } from '../../Tool.js';
import { logForDebugging } from '../debug.js';
import { checkComputerUseLock, tryAcquireComputerUseLock } from './computerUseLock.js';
import { registerEscHotkey } from './escHotkey.js';
import { getChicagoCoordinateMode } from './gates.js';
import { getComputerUseHostAdapter } from './hostAdapter.js';
import { getComputerUseMCPRenderingOverrides } from './toolRendering.js';
type CallOverride = Pick<Tool, 'call'>['call'];
import {
bindSessionContext,
type ComputerUseSessionContext,
type CuCallToolResult,
type CuPermissionRequest,
type CuPermissionResponse,
DEFAULT_GRANT_FLAGS,
type ScreenshotDims,
} from '@ant/computer-use-mcp'
import * as React from 'react'
import { getSessionId } from '../../bootstrap/state.js'
import { ComputerUseApproval } from '../../components/permissions/ComputerUseApproval/ComputerUseApproval.js'
import type { Tool, ToolUseContext } from '../../Tool.js'
import { logForDebugging } from '../debug.js'
import {
checkComputerUseLock,
tryAcquireComputerUseLock,
} from './computerUseLock.js'
import { registerEscHotkey } from './escHotkey.js'
import { getChicagoCoordinateMode } from './gates.js'
import { getComputerUseHostAdapter } from './hostAdapter.js'
import { getComputerUseMCPRenderingOverrides } from './toolRendering.js'
type CallOverride = Pick<Tool, 'call'>['call']
type Binding = {
ctx: ComputerUseSessionContext;
dispatch: (name: string, args: unknown) => Promise<CuCallToolResult>;
};
ctx: ComputerUseSessionContext
dispatch: (name: string, args: unknown) => Promise<CuCallToolResult>
}
/**
* Cached binding — built on first `.call()`, reused for process lifetime.
@@ -46,35 +59,47 @@ type Binding = {
* its internal screenshot blob survives, but `ToolUseContext` is per-call.
* Tests will need to either inject the cache or run serially.
*/
let binding: Binding | undefined;
let currentToolUseContext: ToolUseContext | undefined;
let binding: Binding | undefined
let currentToolUseContext: ToolUseContext | undefined
function tuc(): ToolUseContext {
// Safe: `binding` is only populated when `currentToolUseContext` is set.
// Called only from within `ctx` callbacks, which only fire during dispatch.
return currentToolUseContext!;
return currentToolUseContext!
}
function formatLockHeld(holder: string): string {
return `Computer use is in use by another Claude session (${holder.slice(0, 8)}…). Wait for that session to finish or run /exit there.`;
return `Computer use is in use by another Claude session (${holder.slice(0, 8)}…). Wait for that session to finish or run /exit there.`
}
export function buildSessionContext(): ComputerUseSessionContext {
return {
// ── Read state fresh via the per-call ref ─────────────────────────────
getAllowedApps: () => tuc().getAppState().computerUseMcpState?.allowedApps ?? [],
getGrantFlags: () => tuc().getAppState().computerUseMcpState?.grantFlags ?? DEFAULT_GRANT_FLAGS,
getAllowedApps: () =>
tuc().getAppState().computerUseMcpState?.allowedApps ?? [],
getGrantFlags: () =>
tuc().getAppState().computerUseMcpState?.grantFlags ??
DEFAULT_GRANT_FLAGS,
// cc-2 has no Settings page for user-denied apps yet.
getUserDeniedBundleIds: () => [],
getSelectedDisplayId: () => tuc().getAppState().computerUseMcpState?.selectedDisplayId,
getDisplayPinnedByModel: () => tuc().getAppState().computerUseMcpState?.displayPinnedByModel ?? false,
getDisplayResolvedForApps: () => tuc().getAppState().computerUseMcpState?.displayResolvedForApps,
getSelectedDisplayId: () =>
tuc().getAppState().computerUseMcpState?.selectedDisplayId,
getDisplayPinnedByModel: () =>
tuc().getAppState().computerUseMcpState?.displayPinnedByModel ?? false,
getDisplayResolvedForApps: () =>
tuc().getAppState().computerUseMcpState?.displayResolvedForApps,
getLastScreenshotDims: (): ScreenshotDims | undefined => {
const d = tuc().getAppState().computerUseMcpState?.lastScreenshotDims;
return d ? {
...d,
displayId: d.displayId ?? 0,
originX: d.originX ?? 0,
originY: d.originY ?? 0
} : undefined;
const d = tuc().getAppState().computerUseMcpState?.lastScreenshotDims
return d
? {
...d,
displayId: d.displayId ?? 0,
originX: d.originX ?? 0,
originY: d.originY ?? 0,
}
: undefined
},
// ── Write-backs ────────────────────────────────────────────────────────
// `setToolJSX` is guaranteed present — the gate in `main.tsx` excludes
// non-interactive sessions. The package's `_dialogSignal` (tool-finished
@@ -82,122 +107,143 @@ export function buildSessionContext(): ComputerUseSessionContext {
// the dialog can't outlive it. Ctrl+C is what matters, and
// `runPermissionDialog` wires that from the per-call ref's abortController.
onPermissionRequest: (req, _dialogSignal) => runPermissionDialog(req),
// Package does the merge (dedupe + truthy-only flags). We just persist.
onAllowedAppsChanged: (apps, flags) => tuc().setAppState(prev => {
const cu = prev.computerUseMcpState;
const prevApps = cu?.allowedApps;
const prevFlags = cu?.grantFlags;
const sameApps = prevApps?.length === apps.length && apps.every((a, i) => prevApps[i]?.bundleId === a.bundleId);
const sameFlags = prevFlags?.clipboardRead === flags.clipboardRead && prevFlags?.clipboardWrite === flags.clipboardWrite && prevFlags?.systemKeyCombos === flags.systemKeyCombos;
return sameApps && sameFlags ? prev : {
...prev,
computerUseMcpState: {
...cu,
allowedApps: [...apps],
grantFlags: flags
}
};
}),
onAppsHidden: ids => {
if (ids.length === 0) return;
onAllowedAppsChanged: (apps, flags) =>
tuc().setAppState(prev => {
const cu = prev.computerUseMcpState;
const existing = cu?.hiddenDuringTurn;
if (existing && ids.every(id => existing.has(id))) return prev;
const cu = prev.computerUseMcpState
const prevApps = cu?.allowedApps
const prevFlags = cu?.grantFlags
const sameApps =
prevApps?.length === apps.length &&
apps.every((a, i) => prevApps[i]?.bundleId === a.bundleId)
const sameFlags =
prevFlags?.clipboardRead === flags.clipboardRead &&
prevFlags?.clipboardWrite === flags.clipboardWrite &&
prevFlags?.systemKeyCombos === flags.systemKeyCombos
return sameApps && sameFlags
? prev
: {
...prev,
computerUseMcpState: {
...cu,
allowedApps: [...apps],
grantFlags: flags,
},
}
}),
onAppsHidden: ids => {
if (ids.length === 0) return
tuc().setAppState(prev => {
const cu = prev.computerUseMcpState
const existing = cu?.hiddenDuringTurn
if (existing && ids.every(id => existing.has(id))) return prev
return {
...prev,
computerUseMcpState: {
...cu,
hiddenDuringTurn: new Set([...(existing ?? []), ...ids])
}
};
});
hiddenDuringTurn: new Set([...(existing ?? []), ...ids]),
},
}
})
},
// Resolver writeback only fires under a pin when Swift fell back to main
// (pinned display unplugged) — the pin is semantically dead, so clear it
// and the app-set key so the chase chain runs next time. When autoResolve
// was true, onDisplayResolvedForApps re-sets the key in the same tick.
onResolvedDisplayUpdated: id => tuc().setAppState(prev => {
const cu = prev.computerUseMcpState;
if (cu?.selectedDisplayId === id && !cu.displayPinnedByModel && cu.displayResolvedForApps === undefined) {
return prev;
}
return {
...prev,
computerUseMcpState: {
...cu,
selectedDisplayId: id,
displayPinnedByModel: false,
displayResolvedForApps: undefined
onResolvedDisplayUpdated: id =>
tuc().setAppState(prev => {
const cu = prev.computerUseMcpState
if (
cu?.selectedDisplayId === id &&
!cu.displayPinnedByModel &&
cu.displayResolvedForApps === undefined
) {
return prev
}
};
}),
return {
...prev,
computerUseMcpState: {
...cu,
selectedDisplayId: id,
displayPinnedByModel: false,
displayResolvedForApps: undefined,
},
}
}),
// switch_display(name) pins; switch_display("auto") unpins and clears the
// app-set key so the next screenshot auto-resolves fresh.
onDisplayPinned: id => tuc().setAppState(prev => {
const cu = prev.computerUseMcpState;
const pinned = id !== undefined;
const nextResolvedFor = pinned ? cu?.displayResolvedForApps : undefined;
if (cu?.selectedDisplayId === id && cu?.displayPinnedByModel === pinned && cu?.displayResolvedForApps === nextResolvedFor) {
return prev;
}
return {
...prev,
computerUseMcpState: {
...cu,
selectedDisplayId: id,
displayPinnedByModel: pinned,
displayResolvedForApps: nextResolvedFor
onDisplayPinned: id =>
tuc().setAppState(prev => {
const cu = prev.computerUseMcpState
const pinned = id !== undefined
const nextResolvedFor = pinned ? cu?.displayResolvedForApps : undefined
if (
cu?.selectedDisplayId === id &&
cu?.displayPinnedByModel === pinned &&
cu?.displayResolvedForApps === nextResolvedFor
) {
return prev
}
};
}),
onDisplayResolvedForApps: key => tuc().setAppState(prev => {
const cu = prev.computerUseMcpState;
if (cu?.displayResolvedForApps === key) return prev;
return {
...prev,
computerUseMcpState: {
...cu,
displayResolvedForApps: key
return {
...prev,
computerUseMcpState: {
...cu,
selectedDisplayId: id,
displayPinnedByModel: pinned,
displayResolvedForApps: nextResolvedFor,
},
}
};
}),
onScreenshotCaptured: dims => tuc().setAppState(prev => {
const cu = prev.computerUseMcpState;
const p = cu?.lastScreenshotDims;
return p?.width === dims.width && p?.height === dims.height && p?.displayWidth === dims.displayWidth && p?.displayHeight === dims.displayHeight && p?.displayId === dims.displayId && p?.originX === dims.originX && p?.originY === dims.originY ? prev : {
...prev,
computerUseMcpState: {
...cu,
lastScreenshotDims: dims
}),
onDisplayResolvedForApps: key =>
tuc().setAppState(prev => {
const cu = prev.computerUseMcpState
if (cu?.displayResolvedForApps === key) return prev
return {
...prev,
computerUseMcpState: { ...cu, displayResolvedForApps: key },
}
};
}),
}),
onScreenshotCaptured: dims =>
tuc().setAppState(prev => {
const cu = prev.computerUseMcpState
const p = cu?.lastScreenshotDims
return p?.width === dims.width &&
p?.height === dims.height &&
p?.displayWidth === dims.displayWidth &&
p?.displayHeight === dims.displayHeight &&
p?.displayId === dims.displayId &&
p?.originX === dims.originX &&
p?.originY === dims.originY
? prev
: {
...prev,
computerUseMcpState: { ...cu, lastScreenshotDims: dims },
}
}),
// ── Lock — async, direct file-lock calls ───────────────────────────────
// No `lockHolderForGate` dance: the package's gate is async now. It
// awaits `checkCuLock`, and on `holder: undefined` + non-deferring tool
// awaits `acquireCuLock`. `defersLockAcquire` is the PACKAGE's set —
// the local copy is gone.
checkCuLock: async () => {
const c = await checkComputerUseLock();
const c = await checkComputerUseLock()
switch (c.kind) {
case 'free':
return {
holder: undefined,
isSelf: false
};
return { holder: undefined, isSelf: false }
case 'held_by_self':
return {
holder: getSessionId(),
isSelf: true
};
return { holder: getSessionId(), isSelf: true }
case 'blocked':
return {
holder: c.by,
isSelf: false
};
return { holder: c.by, isSelf: false }
}
},
// Called only when checkCuLock returned `holder: undefined`. The O_EXCL
// acquire is atomic — if another process grabbed it in the gap (rare),
// throw so the tool fails instead of proceeding without the lock.
@@ -205,9 +251,9 @@ export function buildSessionContext(): ComputerUseSessionContext {
// but is possible under parallel tool-use interleaving — don't spam the
// notification in that case.
acquireCuLock: async () => {
const r = await tryAcquireComputerUseLock();
const r = await tryAcquireComputerUseLock()
if (r.kind === 'blocked') {
throw new Error(formatLockHeld(r.by));
throw new Error(formatLockHeld(r.by))
}
if (r.fresh) {
// Global Escape → abort. Consumes the event (PI defense — prompt
@@ -215,26 +261,34 @@ export function buildSessionContext(): ComputerUseSessionContext {
// CFRunLoopSource is processed by the drainRunLoop pump, so this
// holds a pump retain until unregisterEscHotkey() in cleanup.ts.
const escRegistered = registerEscHotkey(() => {
logForDebugging('[cu-esc] user escape, aborting turn');
tuc().abortController.abort();
});
logForDebugging('[cu-esc] user escape, aborting turn')
tuc().abortController.abort()
})
tuc().sendOSNotification?.({
message: escRegistered ? 'Claude is using your computer · press Esc to stop' : 'Claude is using your computer · press Ctrl+C to stop',
notificationType: 'computer_use_enter'
});
message: escRegistered
? 'Claude is using your computer · press Esc to stop'
: 'Claude is using your computer · press Ctrl+C to stop',
notificationType: 'computer_use_enter',
})
}
},
formatLockHeldMessage: formatLockHeld
};
formatLockHeldMessage: formatLockHeld,
}
}
function getOrBind(): Binding {
if (binding) return binding;
const ctx = buildSessionContext();
if (binding) return binding
const ctx = buildSessionContext()
binding = {
ctx,
dispatch: bindSessionContext(getComputerUseHostAdapter(), getChicagoCoordinateMode(), ctx)
};
return binding;
dispatch: bindSessionContext(
getComputerUseHostAdapter(),
getChicagoCoordinateMode(),
ctx,
),
}
return binding
}
/**
@@ -242,21 +296,25 @@ function getOrBind(): Binding {
* tool: rendering overrides from `toolRendering.tsx` plus a `.call()` that
* dispatches through the cached binder.
*/
type ComputerUseMCPToolOverrides = ReturnType<typeof getComputerUseMCPRenderingOverrides> & {
call: CallOverride;
};
export function getComputerUseMCPToolOverrides(toolName: string): ComputerUseMCPToolOverrides {
type ComputerUseMCPToolOverrides = ReturnType<
typeof getComputerUseMCPRenderingOverrides
> & {
call: CallOverride
}
export function getComputerUseMCPToolOverrides(
toolName: string,
): ComputerUseMCPToolOverrides {
const call: CallOverride = async (args, context: ToolUseContext) => {
currentToolUseContext = context;
const {
dispatch
} = getOrBind();
const {
telemetry,
...result
} = await dispatch(toolName, args);
currentToolUseContext = context
const { dispatch } = getOrBind()
const { telemetry, ...result } = await dispatch(toolName, args)
if (telemetry?.error_kind) {
logForDebugging(`[Computer Use MCP] ${toolName} error_kind=${telemetry.error_kind}`);
logForDebugging(
`[Computer Use MCP] ${toolName} error_kind=${telemetry.error_kind}`,
)
}
// MCP content blocks → Anthropic API blocks. CU only produces text and
@@ -265,25 +323,30 @@ export function getComputerUseMCPToolOverrides(toolName: string): ComputerUseMCP
// shape just maps to the API's base64-source shape. The package's result
// type admits audio/resource too, but CU's handleToolCall never emits
// those; the fallthrough coerces them to empty text.
const data = Array.isArray(result.content) ? result.content.map(item => item.type === 'image' ? {
type: 'image' as const,
source: {
type: 'base64' as const,
media_type: item.mimeType ?? 'image/jpeg',
data: item.data
}
} : {
type: 'text' as const,
text: item.type === 'text' ? item.text : ''
}) : result.content;
return {
data
};
};
const data = Array.isArray(result.content)
? result.content.map(item =>
item.type === 'image'
? {
type: 'image' as const,
source: {
type: 'base64' as const,
media_type: item.mimeType ?? 'image/jpeg',
data: item.data,
},
}
: {
type: 'text' as const,
text: item.type === 'text' ? item.text : '',
},
)
: result.content
return { data }
}
return {
...getComputerUseMCPRenderingOverrides(toolName),
call
};
call,
}
}
/**
@@ -293,43 +356,43 @@ export function getComputerUseMCPToolOverrides(toolName: string): ComputerUseMCP
* The merge-into-AppState that used to live here (dedupe + truthy-only flags)
* is now in the package's `bindSessionContext` → `onAllowedAppsChanged`.
*/
async function runPermissionDialog(req: CuPermissionRequest): Promise<CuPermissionResponse> {
const context = tuc();
const setToolJSX = context.setToolJSX;
async function runPermissionDialog(
req: CuPermissionRequest,
): Promise<CuPermissionResponse> {
const context = tuc()
const setToolJSX = context.setToolJSX
if (!setToolJSX) {
// Shouldn't happen — main.tsx gate excludes non-interactive. Fail safe.
return {
granted: [],
denied: [],
flags: DEFAULT_GRANT_FLAGS
};
return { granted: [], denied: [], flags: DEFAULT_GRANT_FLAGS }
}
try {
return await new Promise<CuPermissionResponse>((resolve, reject) => {
const signal = context.abortController.signal;
const signal = context.abortController.signal
// If already aborted, addEventListener won't fire — reject now so the
// promise doesn't hang waiting for a user who Ctrl+C'd.
if (signal.aborted) {
reject(new Error('Computer Use permission dialog aborted'));
return;
reject(new Error('Computer Use permission dialog aborted'))
return
}
const onAbort = (): void => {
signal.removeEventListener('abort', onAbort);
reject(new Error('Computer Use permission dialog aborted'));
};
signal.addEventListener('abort', onAbort);
signal.removeEventListener('abort', onAbort)
reject(new Error('Computer Use permission dialog aborted'))
}
signal.addEventListener('abort', onAbort)
setToolJSX({
jsx: React.createElement(ComputerUseApproval, {
request: req,
onDone: (resp: CuPermissionResponse) => {
signal.removeEventListener('abort', onAbort);
resolve(resp);
}
signal.removeEventListener('abort', onAbort)
resolve(resp)
},
}),
shouldHidePromptInput: true
});
});
shouldHidePromptInput: true,
})
})
} finally {
setToolJSX(null);
setToolJSX(null)
}
}

View File

@@ -1,13 +1,13 @@
import React, { useRef } from 'react';
import stripAnsi from 'strip-ansi';
import { Messages } from '../components/Messages.js';
import { KeybindingProvider } from '../keybindings/KeybindingContext.js';
import { loadKeybindingsSyncWithWarnings } from '../keybindings/loadUserBindings.js';
import type { KeybindingContextName } from '../keybindings/types.js';
import { AppStateProvider } from '../state/AppState.js';
import type { Tools } from '../Tool.js';
import type { Message } from '../types/message.js';
import { renderToAnsiString } from './staticRender.js';
import React, { useRef } from 'react'
import stripAnsi from 'strip-ansi'
import { Messages } from '../components/Messages.js'
import { KeybindingProvider } from '../keybindings/KeybindingContext.js'
import { loadKeybindingsSyncWithWarnings } from '../keybindings/loadUserBindings.js'
import type { KeybindingContextName } from '../keybindings/types.js'
import { AppStateProvider } from '../state/AppState.js'
import type { Tools } from '../Tool.js'
import type { Message } from '../types/message.js'
import { renderToAnsiString } from './staticRender.js'
/**
* Minimal keybinding provider for static/headless renders.
@@ -15,19 +15,29 @@ import { renderToAnsiString } from './staticRender.js';
* and would hang in headless renders with no stdin).
*/
function StaticKeybindingProvider({
children
children,
}: {
children: React.ReactNode;
children: React.ReactNode
}): React.ReactNode {
const {
bindings
} = loadKeybindingsSyncWithWarnings();
const pendingChordRef = useRef(null);
const handlerRegistryRef = useRef(new Map());
const activeContexts = useRef(new Set<KeybindingContextName>()).current;
return <KeybindingProvider bindings={bindings} pendingChordRef={pendingChordRef} pendingChord={null} setPendingChord={() => {}} activeContexts={activeContexts} registerActiveContext={() => {}} unregisterActiveContext={() => {}} handlerRegistryRef={handlerRegistryRef}>
const { bindings } = loadKeybindingsSyncWithWarnings()
const pendingChordRef = useRef(null)
const handlerRegistryRef = useRef(new Map())
const activeContexts = useRef(new Set<KeybindingContextName>()).current
return (
<KeybindingProvider
bindings={bindings}
pendingChordRef={pendingChordRef}
pendingChord={null}
setPendingChord={() => {}}
activeContexts={activeContexts}
registerActiveContext={() => {}}
unregisterActiveContext={() => {}}
handlerRegistryRef={handlerRegistryRef}
>
{children}
</KeybindingProvider>;
</KeybindingProvider>
)
}
// Upper-bound how many NormalizedMessages a Message can produce.
@@ -35,9 +45,9 @@ function StaticKeybindingProvider({
// NormalizedMessages — 1:1 with block count. String content = 1 block.
// AttachmentMessage etc. have no .message and normalize to ≤1.
function normalizedUpperBound(m: Message): number {
if (!('message' in m)) return 1;
const c = m.message.content;
return Array.isArray(c) ? c.length : 1;
if (!('message' in m)) return 1
const c = m.message.content
return Array.isArray(c) ? c.length : 1
}
/**
@@ -52,35 +62,59 @@ function normalizedUpperBound(m: Message): number {
* the full normalized array so tool_use↔tool_result resolves regardless of
* which chunk each landed in.
*/
export async function streamRenderedMessages(messages: Message[], tools: Tools, sink: (ansiChunk: string) => void | Promise<void>, {
columns,
verbose = false,
chunkSize = 40,
onProgress
}: {
columns?: number;
verbose?: boolean;
chunkSize?: number;
onProgress?: (rendered: number) => void;
} = {}): Promise<void> {
const renderChunk = (range: readonly [number, number]) => renderToAnsiString(<AppStateProvider>
export async function streamRenderedMessages(
messages: Message[],
tools: Tools,
sink: (ansiChunk: string) => void | Promise<void>,
{
columns,
verbose = false,
chunkSize = 40,
onProgress,
}: {
columns?: number
verbose?: boolean
chunkSize?: number
onProgress?: (rendered: number) => void
} = {},
): Promise<void> {
const renderChunk = (range: readonly [number, number]) =>
renderToAnsiString(
<AppStateProvider>
<StaticKeybindingProvider>
<Messages messages={messages} tools={tools} commands={[]} verbose={verbose} toolJSX={null} toolUseConfirmQueue={[]} inProgressToolUseIDs={new Set()} isMessageSelectorVisible={false} conversationId="export" screen="prompt" streamingToolUses={[]} showAllInTranscript={true} isLoading={false} renderRange={range} />
<Messages
messages={messages}
tools={tools}
commands={[]}
verbose={verbose}
toolJSX={null}
toolUseConfirmQueue={[]}
inProgressToolUseIDs={new Set()}
isMessageSelectorVisible={false}
conversationId="export"
screen="prompt"
streamingToolUses={[]}
showAllInTranscript={true}
isLoading={false}
renderRange={range}
/>
</StaticKeybindingProvider>
</AppStateProvider>, columns);
</AppStateProvider>,
columns,
)
// renderRange indexes into the post-collapse array whose length we can't
// see from here — normalize splits each Message into one NormalizedMessage
// per content block (unbounded per message), collapse merges some back.
// Ceiling is the exact normalize output count + chunkSize so the loop
// always reaches the empty slice where break fires (collapse only shrinks).
let ceiling = chunkSize;
for (const m of messages) ceiling += normalizedUpperBound(m);
let ceiling = chunkSize
for (const m of messages) ceiling += normalizedUpperBound(m)
for (let offset = 0; offset < ceiling; offset += chunkSize) {
const ansi = await renderChunk([offset, offset + chunkSize]);
if (stripAnsi(ansi).trim() === '') break;
await sink(ansi);
onProgress?.(offset + chunkSize);
const ansi = await renderChunk([offset, offset + chunkSize])
if (stripAnsi(ansi).trim() === '') break
await sink(ansi)
onProgress?.(offset + chunkSize)
}
}
@@ -88,10 +122,17 @@ export async function streamRenderedMessages(messages: Message[], tools: Tools,
* Renders messages to a plain text string suitable for export.
* Uses the same React rendering logic as the interactive UI.
*/
export async function renderMessagesToPlainText(messages: Message[], tools: Tools = [], columns?: number): Promise<string> {
const parts: string[] = [];
await streamRenderedMessages(messages, tools, chunk => void parts.push(stripAnsi(chunk)), {
columns
});
return parts.join('');
export async function renderMessagesToPlainText(
messages: Message[],
tools: Tools = [],
columns?: number,
): Promise<string> {
const parts: string[] = []
await streamRenderedMessages(
messages,
tools,
chunk => void parts.push(stripAnsi(chunk)),
{ columns },
)
return parts.join('')
}

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { Text } from '../ink.js';
import * as React from 'react'
import { Text } from '../ink.js'
/**
* Inverse-highlight every occurrence of `query` in `text` (case-insensitive).
@@ -7,21 +7,23 @@ import { Text } from '../ink.js';
* and preview panes.
*/
export function highlightMatch(text: string, query: string): React.ReactNode {
if (!query) return text;
const queryLower = query.toLowerCase();
const textLower = text.toLowerCase();
const parts: React.ReactNode[] = [];
let offset = 0;
let idx = textLower.indexOf(queryLower, offset);
if (idx === -1) return text;
if (!query) return text
const queryLower = query.toLowerCase()
const textLower = text.toLowerCase()
const parts: React.ReactNode[] = []
let offset = 0
let idx = textLower.indexOf(queryLower, offset)
if (idx === -1) return text
while (idx !== -1) {
if (idx > offset) parts.push(text.slice(offset, idx));
parts.push(<Text key={idx} inverse>
if (idx > offset) parts.push(text.slice(offset, idx))
parts.push(
<Text key={idx} inverse>
{text.slice(idx, idx + query.length)}
</Text>);
offset = idx + query.length;
idx = textLower.indexOf(queryLower, offset);
</Text>,
)
offset = idx + query.length
idx = textLower.indexOf(queryLower, offset)
}
if (offset < text.length) parts.push(text.slice(offset));
return <>{parts}</>;
if (offset < text.length) parts.push(text.slice(offset))
return <>{parts}</>
}

View File

@@ -1,10 +1,14 @@
import { performBackgroundPluginInstallations } from '../../services/plugins/PluginInstallationManager.js';
import type { AppState } from '../../state/AppState.js';
import { checkHasTrustDialogAccepted } from '../config.js';
import { logForDebugging } from '../debug.js';
import { clearMarketplacesCache, registerSeedMarketplaces } from './marketplaceManager.js';
import { clearPluginCache } from './pluginLoader.js';
type SetAppState = (f: (prevState: AppState) => AppState) => void;
import { performBackgroundPluginInstallations } from '../../services/plugins/PluginInstallationManager.js'
import type { AppState } from '../../state/AppState.js'
import { checkHasTrustDialogAccepted } from '../config.js'
import { logForDebugging } from '../debug.js'
import {
clearMarketplacesCache,
registerSeedMarketplaces,
} from './marketplaceManager.js'
import { clearPluginCache } from './pluginLoader.js'
type SetAppState = (f: (prevState: AppState) => AppState) => void
/**
* Perform plugin startup checks and initiate background installations
@@ -21,16 +25,21 @@ type SetAppState = (f: (prevState: AppState) => AppState) => void;
*
* @param setAppState Function to update app state with installation progress
*/
export async function performStartupChecks(setAppState: SetAppState): Promise<void> {
logForDebugging('performStartupChecks called');
export async function performStartupChecks(
setAppState: SetAppState,
): Promise<void> {
logForDebugging('performStartupChecks called')
// Check if the current directory has been trusted
if (!checkHasTrustDialogAccepted()) {
logForDebugging('Trust not accepted for current directory - skipping plugin installations');
return;
logForDebugging(
'Trust not accepted for current directory - skipping plugin installations',
)
return
}
try {
logForDebugging('Starting background plugin installations');
logForDebugging('Starting background plugin installations')
// Register seed marketplaces (CLAUDE_CODE_PLUGIN_SEED_DIR) before diffing.
// Idempotent; no-op if seed not configured. Without this, background install
@@ -39,31 +48,30 @@ export async function performStartupChecks(setAppState: SetAppState): Promise<vo
// If registration changed state, clear caches so earlier plugin-load passes
// (e.g. getAllMcpConfigs during REPL init) don't keep stale "marketplace
// not found" results.
const seedChanged = await registerSeedMarketplaces();
const seedChanged = await registerSeedMarketplaces()
if (seedChanged) {
clearMarketplacesCache();
clearPluginCache('performStartupChecks: seed marketplaces changed');
clearMarketplacesCache()
clearPluginCache('performStartupChecks: seed marketplaces changed')
// Set needsRefresh so useManagePlugins notifies the user to run
// /reload-plugins. Without this signal, the initial plugin-load
// (which raced and cached "marketplace not found") would persist
// until the user manually reloads.
setAppState(prev => {
if (prev.plugins.needsRefresh) return prev;
if (prev.plugins.needsRefresh) return prev
return {
...prev,
plugins: {
...prev.plugins,
needsRefresh: true
}
};
});
plugins: { ...prev.plugins, needsRefresh: true },
}
})
}
// Start background installations without waiting
// This will update AppState as installations progress
await performBackgroundPluginInstallations(setAppState);
await performBackgroundPluginInstallations(setAppState)
} catch (error) {
// Even if something fails here, don't block startup
logForDebugging(`Error initiating background plugin installations: ${error}`);
logForDebugging(
`Error initiating background plugin installations: ${error}`,
)
}
}

View File

@@ -1,134 +1,152 @@
import axios from 'axios';
import React, { useEffect, useState } from 'react';
import { logEvent } from 'src/services/analytics/index.js';
import { Spinner } from '../components/Spinner.js';
import { getOauthConfig } from '../constants/oauth.js';
import { useTimeout } from '../hooks/useTimeout.js';
import { Box, Text } from '../ink.js';
import { getSSLErrorHint } from '../services/api/errorUtils.js';
import { getUserAgent } from './http.js';
import { logError } from './log.js';
import axios from 'axios'
import React, { useEffect, useState } from 'react'
import { logEvent } from 'src/services/analytics/index.js'
import { Spinner } from '../components/Spinner.js'
import { getOauthConfig } from '../constants/oauth.js'
import { useTimeout } from '../hooks/useTimeout.js'
import { Box, Text } from '../ink.js'
import { getSSLErrorHint } from '../services/api/errorUtils.js'
import { getUserAgent } from './http.js'
import { logError } from './log.js'
export interface PreflightCheckResult {
success: boolean;
error?: string;
sslHint?: string;
success: boolean
error?: string
sslHint?: string
}
async function checkEndpoints(): Promise<PreflightCheckResult> {
try {
const oauthConfig = getOauthConfig();
const tokenUrl = new URL(oauthConfig.TOKEN_URL);
const endpoints = [`${oauthConfig.BASE_API_URL}/api/hello`, `${tokenUrl.origin}/v1/oauth/hello`];
const checkEndpoint = async (url: string): Promise<PreflightCheckResult> => {
const oauthConfig = getOauthConfig()
const tokenUrl = new URL(oauthConfig.TOKEN_URL)
const endpoints = [
`${oauthConfig.BASE_API_URL}/api/hello`,
`${tokenUrl.origin}/v1/oauth/hello`,
]
const checkEndpoint = async (
url: string,
): Promise<PreflightCheckResult> => {
try {
const response = await axios.get(url, {
headers: {
'User-Agent': getUserAgent()
}
});
headers: { 'User-Agent': getUserAgent() },
})
if (response.status !== 200) {
const hostname = new URL(url).hostname;
const hostname = new URL(url).hostname
return {
success: false,
error: `Failed to connect to ${hostname}: Status ${response.status}`
};
error: `Failed to connect to ${hostname}: Status ${response.status}`,
}
}
return {
success: true
};
return { success: true }
} catch (error) {
const hostname = new URL(url).hostname;
const sslHint = getSSLErrorHint(error);
const hostname = new URL(url).hostname
const sslHint = getSSLErrorHint(error)
return {
success: false,
error: `Failed to connect to ${hostname}: ${error instanceof Error ? (error as ErrnoException).code || error.message : String(error)}`,
sslHint: sslHint ?? undefined
};
sslHint: sslHint ?? undefined,
}
}
};
const results = await Promise.all(endpoints.map(checkEndpoint));
const failedResult = results.find(result => !result.success);
}
const results = await Promise.all(endpoints.map(checkEndpoint))
const failedResult = results.find(result => !result.success)
if (failedResult) {
// Log failure to Statsig
logEvent('tengu_preflight_check_failed', {
isConnectivityError: false,
hasErrorMessage: !!failedResult.error,
isSSLError: !!failedResult.sslHint
});
isSSLError: !!failedResult.sslHint,
})
}
return failedResult || {
success: true
};
return failedResult || { success: true }
} catch (error) {
logError(error as Error);
logError(error as Error)
// Log to Statsig
logEvent('tengu_preflight_check_failed', {
isConnectivityError: true
});
isConnectivityError: true,
})
return {
success: false,
error: `Connectivity check error: ${error instanceof Error ? (error as ErrnoException).code || error.message : String(error)}`
};
error: `Connectivity check error: ${error instanceof Error ? (error as ErrnoException).code || error.message : String(error)}`,
}
}
}
interface PreflightStepProps {
onSuccess: () => void;
onSuccess: () => void
}
export function PreflightStep({ onSuccess }: PreflightStepProps) {
const [result, setResult] = useState<PreflightCheckResult | null>(null);
const [isChecking, setIsChecking] = useState(true);
const showSpinner = useTimeout(1000) && isChecking;
export function PreflightStep({
onSuccess,
}: PreflightStepProps): React.ReactNode {
const [result, setResult] = useState<PreflightCheckResult | null>(null)
const [isChecking, setIsChecking] = useState(true)
// delay showing the check since it's so fast that we normally
// want to just immediately show the next step without a flash
const showSpinner = useTimeout(1000) && isChecking
useEffect(() => {
checkEndpoints().then((checkResult) => {
setResult(checkResult);
setIsChecking(false);
});
}, []);
async function run() {
const checkResult = await checkEndpoints()
setResult(checkResult)
setIsChecking(false)
}
void run()
}, [])
useEffect(() => {
if (result?.success) {
onSuccess();
onSuccess()
} else if (result && !result.success) {
logError(result.error ?? 'Preflight connectivity check failed');
onSuccess();
const timer = setTimeout(() => process.exit(1), 100)
return () => clearTimeout(timer)
}
}, [result, onSuccess]);
let content = null;
if (isChecking && showSpinner) {
content = (
<Box paddingLeft={1}>
<Spinner />
<Text>Checking connectivity...</Text>
</Box>
);
} else if (!result?.success && !isChecking) {
content = (
<Box flexDirection="column" gap={1}>
<Text color="error">Unable to connect to Anthropic services</Text>
<Text color="error">{result?.error}</Text>
{result?.sslHint ? (
<Box flexDirection="column" gap={1}>
<Text>{result.sslHint}</Text>
<Text color="suggestion">See https://code.claude.com/docs/en/network-config</Text>
</Box>
) : (
<Box flexDirection="column" gap={1}>
<Text>Please check your internet connection and network settings.</Text>
<Text>
Note: Claude Code might not be available in your country. Check supported countries at{" "}
<Text color="suggestion">https://anthropic.com/supported-countries</Text>
</Text>
</Box>
)}
</Box>
);
}
}, [result, onSuccess])
return (
<Box flexDirection="column" gap={1} paddingLeft={1}>
{content}
{isChecking && showSpinner ? (
<Box paddingLeft={1}>
<Spinner />
<Text>Checking connectivity...</Text>
</Box>
) : (
!result?.success &&
!isChecking && (
<Box flexDirection="column" gap={1}>
<Text color="error">Unable to connect to Anthropic services</Text>
<Text color="error">{result?.error}</Text>
{result?.sslHint ? (
<Box flexDirection="column" gap={1}>
<Text>{result.sslHint}</Text>
<Text color="suggestion">
See https://code.claude.com/docs/en/network-config
</Text>
</Box>
) : (
<Box flexDirection="column" gap={1}>
<Text>
Please check your internet connection and network settings.
</Text>
<Text>
Note: Claude Code might not be available in your country.
Check supported countries at{' '}
<Text color="suggestion">
https://anthropic.com/supported-countries
</Text>
</Text>
</Box>
)}
</Box>
)
)}
</Box>
);
)
}

View File

@@ -1,69 +1,97 @@
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources';
import { randomUUID } from 'crypto';
import * as React from 'react';
import { BashModeProgress } from 'src/components/BashModeProgress.js';
import type { SetToolJSXFn } from 'src/Tool.js';
import { BashTool } from 'src/tools/BashTool/BashTool.js';
import type { AttachmentMessage, SystemMessage, UserMessage } from 'src/types/message.js';
import type { ShellProgress } from 'src/types/tools.js';
import { logEvent } from '../../services/analytics/index.js';
import { errorMessage, ShellError } from '../errors.js';
import { createSyntheticUserCaveatMessage, createUserInterruptionMessage, createUserMessage, prepareUserContent } from '../messages.js';
import { resolveDefaultShell } from '../shell/resolveDefaultShell.js';
import { isPowerShellToolEnabled } from '../shell/shellToolUtils.js';
import { processToolResultBlock } from '../toolResultStorage.js';
import { escapeXml } from '../xml.js';
import type { ProcessUserInputContext } from './processUserInput.js';
export async function processBashCommand(inputString: string, precedingInputBlocks: ContentBlockParam[], attachmentMessages: AttachmentMessage[], context: ProcessUserInputContext, setToolJSX: SetToolJSXFn): Promise<{
messages: (UserMessage | AttachmentMessage | SystemMessage)[];
shouldQuery: boolean;
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources'
import { randomUUID } from 'crypto'
import * as React from 'react'
import { BashModeProgress } from 'src/components/BashModeProgress.js'
import type { SetToolJSXFn } from 'src/Tool.js'
import { BashTool } from 'src/tools/BashTool/BashTool.js'
import type {
AttachmentMessage,
SystemMessage,
UserMessage,
} from 'src/types/message.js'
import type { ShellProgress } from 'src/types/tools.js'
import { logEvent } from '../../services/analytics/index.js'
import { errorMessage, ShellError } from '../errors.js'
import {
createSyntheticUserCaveatMessage,
createUserInterruptionMessage,
createUserMessage,
prepareUserContent,
} from '../messages.js'
import { resolveDefaultShell } from '../shell/resolveDefaultShell.js'
import { isPowerShellToolEnabled } from '../shell/shellToolUtils.js'
import { processToolResultBlock } from '../toolResultStorage.js'
import { escapeXml } from '../xml.js'
import type { ProcessUserInputContext } from './processUserInput.js'
export async function processBashCommand(
inputString: string,
precedingInputBlocks: ContentBlockParam[],
attachmentMessages: AttachmentMessage[],
context: ProcessUserInputContext,
setToolJSX: SetToolJSXFn,
): Promise<{
messages: (UserMessage | AttachmentMessage | SystemMessage)[]
shouldQuery: boolean
}> {
// Shell routing (docs/design/ps-shell-selection.md §5.2): consult
// defaultShell, fall back to bash. isPowerShellToolEnabled() applies the
// same platform + env-var gate as tools.ts so input-box routing matches
// tool-list visibility. Computed up front so telemetry records the
// actual shell, not the raw setting.
const usePowerShell = isPowerShellToolEnabled() && resolveDefaultShell() === 'powershell';
logEvent('tengu_input_bash', {
powershell: usePowerShell
});
const usePowerShell =
isPowerShellToolEnabled() && resolveDefaultShell() === 'powershell'
logEvent('tengu_input_bash', { powershell: usePowerShell })
const userMessage = createUserMessage({
content: prepareUserContent({
inputString: `<bash-input>${inputString}</bash-input>`,
precedingInputBlocks
})
});
precedingInputBlocks,
}),
})
// ctrl+b to background indicator
let jsx: React.ReactNode;
let jsx: React.ReactNode
// Just show initial UI
setToolJSX({
jsx: <BashModeProgress input={inputString} progress={null} verbose={context.options.verbose} />,
shouldHidePromptInput: false
});
jsx: (
<BashModeProgress
input={inputString}
progress={null}
verbose={context.options.verbose}
/>
),
shouldHidePromptInput: false,
})
try {
const bashModeContext: ProcessUserInputContext = {
...context,
// TODO: Clean up this hack
setToolJSX: _ => {
jsx = _?.jsx;
}
};
jsx = _?.jsx
},
}
// Progress UI — shared across both shell backends (both emit ShellProgress)
const onProgress = (progress: {
data: ShellProgress;
}) => {
const onProgress = (progress: { data: ShellProgress }) => {
setToolJSX({
jsx: <>
<BashModeProgress input={inputString!} progress={progress.data} verbose={context.options.verbose} />
jsx: (
<>
<BashModeProgress
input={inputString!}
progress={progress.data}
verbose={context.options.verbose}
/>
{jsx}
</>,
</>
),
shouldHidePromptInput: false,
showSpinner: false
});
};
showSpinner: false,
})
}
// User-initiated `!` commands run outside sandbox. Both shell tools honor
// dangerouslyDisableSandbox (checked against areUnsandboxedCommandsAllowed()
@@ -71,69 +99,107 @@ export async function processBashCommand(inputString: string, precedingInputBloc
// native, shouldUseSandbox() returns false regardless (unsupported platform).
// Lazy-require PowerShellTool so its ~300KB chunk only loads when the
// user has actually selected the powershell default shell.
type PSMod = typeof import('src/tools/PowerShellTool/PowerShellTool.js');
let PowerShellTool: PSMod['PowerShellTool'] | null = null;
type PSMod = typeof import('src/tools/PowerShellTool/PowerShellTool.js')
let PowerShellTool: PSMod['PowerShellTool'] | null = null
if (usePowerShell) {
/* eslint-disable @typescript-eslint/no-require-imports */
PowerShellTool = (require('src/tools/PowerShellTool/PowerShellTool.js') as PSMod).PowerShellTool;
PowerShellTool = (
require('src/tools/PowerShellTool/PowerShellTool.js') as PSMod
).PowerShellTool
/* eslint-enable @typescript-eslint/no-require-imports */
}
const shellTool = PowerShellTool ?? BashTool;
const response = PowerShellTool ? await PowerShellTool.call({
command: inputString,
dangerouslyDisableSandbox: true
}, bashModeContext, undefined, undefined, onProgress) : await BashTool.call({
command: inputString,
dangerouslyDisableSandbox: true
}, bashModeContext, undefined, undefined, onProgress);
const data = response.data;
const shellTool = PowerShellTool ?? BashTool
const response = PowerShellTool
? await PowerShellTool.call(
{ command: inputString, dangerouslyDisableSandbox: true },
bashModeContext,
undefined,
undefined,
onProgress,
)
: await BashTool.call(
{
command: inputString,
dangerouslyDisableSandbox: true,
},
bashModeContext,
undefined,
undefined,
onProgress,
)
const data = response.data
if (!data) {
throw new Error('No result received from shell command');
throw new Error('No result received from shell command')
}
const stderr = data.stderr;
const stderr = data.stderr
// Reuse the same formatting pipeline as inline !`cmd` bash (promptShellExecution)
// and model-initiated Bash. When BashTool.call() persists large output to disk,
// data.persistedOutputPath is set and the formatter wraps in <persisted-output>.
// Pass stderr:'' to keep it separate for the <bash-stderr> UI tag.
const mapped = await processToolResultBlock(shellTool, {
...data,
stderr: ''
}, randomUUID());
const mapped = await processToolResultBlock(
shellTool,
{ ...data, stderr: '' },
randomUUID(),
)
// mapped.content may contain our own <persisted-output> wrapper (trusted
// XML from buildLargeToolResultMessage). Escaping it would turn structural
// tags into &lt;persisted-output&gt;, breaking the model's parse and
// UserBashOutputMessage's extractTag. Escape the raw fallback only.
const stdout = typeof mapped.content === 'string' ? mapped.content : escapeXml(data.stdout);
const stdout =
typeof mapped.content === 'string'
? mapped.content
: escapeXml(data.stdout)
return {
messages: [createSyntheticUserCaveatMessage(), userMessage, ...attachmentMessages, createUserMessage({
content: `<bash-stdout>${stdout}</bash-stdout><bash-stderr>${escapeXml(stderr)}</bash-stderr>`
})],
shouldQuery: false
};
messages: [
createSyntheticUserCaveatMessage(),
userMessage,
...attachmentMessages,
createUserMessage({
content: `<bash-stdout>${stdout}</bash-stdout><bash-stderr>${escapeXml(stderr)}</bash-stderr>`,
}),
],
shouldQuery: false,
}
} catch (e) {
if (e instanceof ShellError) {
if (e.interrupted) {
return {
messages: [createSyntheticUserCaveatMessage(), userMessage, createUserInterruptionMessage({
toolUse: false
}), ...attachmentMessages],
shouldQuery: false
};
messages: [
createSyntheticUserCaveatMessage(),
userMessage,
createUserInterruptionMessage({ toolUse: false }),
...attachmentMessages,
],
shouldQuery: false,
}
}
return {
messages: [createSyntheticUserCaveatMessage(), userMessage, ...attachmentMessages, createUserMessage({
content: `<bash-stdout>${escapeXml(e.stdout)}</bash-stdout><bash-stderr>${escapeXml(e.stderr)}</bash-stderr>`
})],
shouldQuery: false
};
messages: [
createSyntheticUserCaveatMessage(),
userMessage,
...attachmentMessages,
createUserMessage({
content: `<bash-stdout>${escapeXml(e.stdout)}</bash-stdout><bash-stderr>${escapeXml(e.stderr)}</bash-stderr>`,
}),
],
shouldQuery: false,
}
}
return {
messages: [createSyntheticUserCaveatMessage(), userMessage, ...attachmentMessages, createUserMessage({
content: `<bash-stderr>Command failed: ${escapeXml(errorMessage(e))}</bash-stderr>`
})],
shouldQuery: false
};
messages: [
createSyntheticUserCaveatMessage(),
userMessage,
...attachmentMessages,
createUserMessage({
content: `<bash-stderr>Command failed: ${escapeXml(errorMessage(e))}</bash-stderr>`,
}),
],
shouldQuery: false,
}
} finally {
setToolJSX(null);
setToolJSX(null)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,8 @@
import { c as _c } from "react/compiler-runtime";
import * as React from 'react';
import { useLayoutEffect } from 'react';
import { PassThrough } from 'stream';
import stripAnsi from 'strip-ansi';
import { render, useApp } from '../ink.js';
import * as React from 'react'
import { useLayoutEffect } from 'react'
import { PassThrough } from 'stream'
import stripAnsi from 'strip-ansi'
import { render, useApp } from '../ink.js'
// This is a workaround for the fact that Ink doesn't support multiple <Static>
// components in the same render tree. Instead of using a <Static> we just render
@@ -15,44 +14,26 @@ import { render, useApp } from '../ink.js';
* before exiting. This is more robust than process.nextTick() for React 19's
* async render cycle.
*/
function RenderOnceAndExit(t0) {
const $ = _c(5);
const {
children
} = t0;
const {
exit
} = useApp();
let t1;
let t2;
if ($[0] !== exit) {
t1 = () => {
const timer = setTimeout(exit, 0);
return () => clearTimeout(timer);
};
t2 = [exit];
$[0] = exit;
$[1] = t1;
$[2] = t2;
} else {
t1 = $[1];
t2 = $[2];
}
useLayoutEffect(t1, t2);
let t3;
if ($[3] !== children) {
t3 = <>{children}</>;
$[3] = children;
$[4] = t3;
} else {
t3 = $[4];
}
return t3;
function RenderOnceAndExit({
children,
}: {
children: React.ReactNode
}): React.ReactNode {
const { exit } = useApp()
// useLayoutEffect runs synchronously after React commits DOM mutations.
// setTimeout(0) defers exit to allow Ink to flush output to the stream.
useLayoutEffect(() => {
const timer = setTimeout(exit, 0)
return () => clearTimeout(timer)
}, [exit])
return <>{children}</>
}
// DEC synchronized update markers used by terminals
const SYNC_START = '\x1B[?2026h';
const SYNC_END = '\x1B[?2026l';
const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l'
/**
* Extracts content from the first complete frame in Ink's output.
@@ -60,56 +41,64 @@ const SYNC_END = '\x1B[?2026l';
* update sequences ([?2026h ... [?2026l). We only want the first frame's content.
*/
function extractFirstFrame(output: string): string {
const startIndex = output.indexOf(SYNC_START);
if (startIndex === -1) return output;
const contentStart = startIndex + SYNC_START.length;
const endIndex = output.indexOf(SYNC_END, contentStart);
if (endIndex === -1) return output;
return output.slice(contentStart, endIndex);
const startIndex = output.indexOf(SYNC_START)
if (startIndex === -1) return output
const contentStart = startIndex + SYNC_START.length
const endIndex = output.indexOf(SYNC_END, contentStart)
if (endIndex === -1) return output
return output.slice(contentStart, endIndex)
}
/**
* Renders a React node to a string with ANSI escape codes (for terminal output).
*/
export function renderToAnsiString(node: React.ReactNode, columns?: number): Promise<string> {
export function renderToAnsiString(
node: React.ReactNode,
columns?: number,
): Promise<string> {
return new Promise(async resolve => {
let output = '';
let output = ''
// Capture all writes. Set .columns so Ink (ink.tsx:~165) picks up a
// chosen width instead of PassThrough's undefined → 80 fallback —
// useful for rendering at terminal width for file dumps that should
// match what the user sees on screen.
const stream = new PassThrough();
const stream = new PassThrough()
if (columns !== undefined) {
;
(stream as unknown as {
columns: number;
}).columns = columns;
;(stream as unknown as { columns: number }).columns = columns
}
stream.on('data', chunk => {
output += chunk.toString();
});
output += chunk.toString()
})
// Render the component wrapped in RenderOnceAndExit
// Non-TTY stdout (PassThrough) gives full-frame output instead of diffs
const instance = await render(<RenderOnceAndExit>{node}</RenderOnceAndExit>, {
stdout: stream as unknown as NodeJS.WriteStream,
patchConsole: false
});
const instance = await render(
<RenderOnceAndExit>{node}</RenderOnceAndExit>,
{
stdout: stream as unknown as NodeJS.WriteStream,
patchConsole: false,
},
)
// Wait for the component to exit naturally
await instance.waitUntilExit();
await instance.waitUntilExit()
// Extract only the first frame's content to avoid duplication
// (Ink outputs multiple frames in non-TTY mode)
await resolve(extractFirstFrame(output));
});
await resolve(extractFirstFrame(output))
})
}
/**
* Renders a React node to a plain text string (ANSI codes stripped).
*/
export async function renderToString(node: React.ReactNode, columns?: number): Promise<string> {
const output = await renderToAnsiString(node, columns);
return stripAnsi(output);
export async function renderToString(
node: React.ReactNode,
columns?: number,
): Promise<string> {
const output = await renderToAnsiString(node, columns)
return stripAnsi(output)
}

View File

@@ -1,361 +1,472 @@
import chalk from 'chalk';
import figures from 'figures';
import * as React from 'react';
import { color, Text } from '../ink.js';
import type { MCPServerConnection } from '../services/mcp/types.js';
import { getAccountInformation, isClaudeAISubscriber } from './auth.js';
import { getLargeMemoryFiles, getMemoryFiles, MAX_MEMORY_CHARACTER_COUNT } from './claudemd.js';
import { getDoctorDiagnostic } from './doctorDiagnostic.js';
import { getAWSRegion, getDefaultVertexRegion, isEnvTruthy } from './envUtils.js';
import { getDisplayPath } from './file.js';
import { formatNumber } from './format.js';
import { getIdeClientName, type IDEExtensionInstallationStatus, isJetBrainsIde, toIDEDisplayName } from './ide.js';
import { getClaudeAiUserDefaultModelDescription, modelDisplayString } from './model/model.js';
import { getAPIProvider } from './model/providers.js';
import { getMTLSConfig } from './mtls.js';
import { checkInstall } from './nativeInstaller/index.js';
import { getProxyUrl } from './proxy.js';
import { SandboxManager } from './sandbox/sandbox-adapter.js';
import { getSettingsWithAllErrors } from './settings/allErrors.js';
import { getEnabledSettingSources, getSettingSourceDisplayNameCapitalized } from './settings/constants.js';
import { getManagedFileSettingsPresence, getPolicySettingsOrigin, getSettingsForSource } from './settings/settings.js';
import type { ThemeName } from './theme.js';
import chalk from 'chalk'
import figures from 'figures'
import * as React from 'react'
import { color, Text } from '../ink.js'
import type { MCPServerConnection } from '../services/mcp/types.js'
import { getAccountInformation, isClaudeAISubscriber } from './auth.js'
import {
getLargeMemoryFiles,
getMemoryFiles,
MAX_MEMORY_CHARACTER_COUNT,
} from './claudemd.js'
import { getDoctorDiagnostic } from './doctorDiagnostic.js'
import {
getAWSRegion,
getDefaultVertexRegion,
isEnvTruthy,
} from './envUtils.js'
import { getDisplayPath } from './file.js'
import { formatNumber } from './format.js'
import {
getIdeClientName,
type IDEExtensionInstallationStatus,
isJetBrainsIde,
toIDEDisplayName,
} from './ide.js'
import {
getClaudeAiUserDefaultModelDescription,
modelDisplayString,
} from './model/model.js'
import { getAPIProvider } from './model/providers.js'
import { getMTLSConfig } from './mtls.js'
import { checkInstall } from './nativeInstaller/index.js'
import { getProxyUrl } from './proxy.js'
import { SandboxManager } from './sandbox/sandbox-adapter.js'
import { getSettingsWithAllErrors } from './settings/allErrors.js'
import {
getEnabledSettingSources,
getSettingSourceDisplayNameCapitalized,
} from './settings/constants.js'
import {
getManagedFileSettingsPresence,
getPolicySettingsOrigin,
getSettingsForSource,
} from './settings/settings.js'
import type { ThemeName } from './theme.js'
export type Property = {
label?: string;
value: React.ReactNode | Array<string>;
};
export type Diagnostic = React.ReactNode;
export function buildSandboxProperties(): Property[] {
if ((process.env.USER_TYPE) !== 'ant') {
return [];
}
const isSandboxed = SandboxManager.isSandboxingEnabled();
return [{
label: 'Bash Sandbox',
value: isSandboxed ? 'Enabled' : 'Disabled'
}];
label?: string
value: React.ReactNode | Array<string>
}
export function buildIDEProperties(mcpClients: MCPServerConnection[], ideInstallationStatus: IDEExtensionInstallationStatus | null = null, theme: ThemeName): Property[] {
const ideClient = mcpClients?.find(client => client.name === 'ide');
export type Diagnostic = React.ReactNode
export function buildSandboxProperties(): Property[] {
if (process.env.USER_TYPE !== 'ant') {
return []
}
const isSandboxed = SandboxManager.isSandboxingEnabled()
return [
{
label: 'Bash Sandbox',
value: isSandboxed ? 'Enabled' : 'Disabled',
},
]
}
export function buildIDEProperties(
mcpClients: MCPServerConnection[],
ideInstallationStatus: IDEExtensionInstallationStatus | null = null,
theme: ThemeName,
): Property[] {
const ideClient = mcpClients?.find(client => client.name === 'ide')
if (ideInstallationStatus) {
const ideName = toIDEDisplayName(ideInstallationStatus.ideType);
const pluginOrExtension = isJetBrainsIde(ideInstallationStatus.ideType) ? 'plugin' : 'extension';
const ideName = toIDEDisplayName(ideInstallationStatus.ideType)
const pluginOrExtension = isJetBrainsIde(ideInstallationStatus.ideType)
? 'plugin'
: 'extension'
if (ideInstallationStatus.error) {
return [{
label: 'IDE',
value: <Text>
return [
{
label: 'IDE',
value: (
<Text>
{color('error', theme)(figures.cross)} Error installing {ideName}{' '}
{pluginOrExtension}: {ideInstallationStatus.error}
{'\n'}Please restart your IDE and try again.
</Text>
}];
),
},
]
}
if (ideInstallationStatus.installed) {
if (ideClient && ideClient.type === 'connected') {
if (ideInstallationStatus.installedVersion !== ideClient.serverInfo?.version) {
return [{
label: 'IDE',
value: `Connected to ${ideName} ${pluginOrExtension} version ${ideInstallationStatus.installedVersion} (server version: ${ideClient.serverInfo?.version})`
}];
if (
ideInstallationStatus.installedVersion !==
ideClient.serverInfo?.version
) {
return [
{
label: 'IDE',
value: `Connected to ${ideName} ${pluginOrExtension} version ${ideInstallationStatus.installedVersion} (server version: ${ideClient.serverInfo?.version})`,
},
]
} else {
return [{
label: 'IDE',
value: `Connected to ${ideName} ${pluginOrExtension} version ${ideInstallationStatus.installedVersion}`
}];
return [
{
label: 'IDE',
value: `Connected to ${ideName} ${pluginOrExtension} version ${ideInstallationStatus.installedVersion}`,
},
]
}
} else {
return [{
label: 'IDE',
value: `Installed ${ideName} ${pluginOrExtension}`
}];
return [
{
label: 'IDE',
value: `Installed ${ideName} ${pluginOrExtension}`,
},
]
}
}
} else if (ideClient) {
const ideName = getIdeClientName(ideClient) ?? 'IDE';
const ideName = getIdeClientName(ideClient) ?? 'IDE'
if (ideClient.type === 'connected') {
return [{
label: 'IDE',
value: `Connected to ${ideName} extension`
}];
return [
{
label: 'IDE',
value: `Connected to ${ideName} extension`,
},
]
} else {
return [{
label: 'IDE',
value: `${color('error', theme)(figures.cross)} Not connected to ${ideName}`
}];
return [
{
label: 'IDE',
value: `${color('error', theme)(figures.cross)} Not connected to ${ideName}`,
},
]
}
}
return [];
return []
}
export function buildMcpProperties(clients: MCPServerConnection[] = [], theme: ThemeName): Property[] {
const servers = clients.filter(client => client.name !== 'ide');
export function buildMcpProperties(
clients: MCPServerConnection[] = [],
theme: ThemeName,
): Property[] {
const servers = clients.filter(client => client.name !== 'ide')
if (!servers.length) {
return [];
return []
}
// Summary instead of a full server list — 20+ servers wrapped onto many
// rows, dominating the Status pane. Show counts by state + /mcp hint.
const byState = {
connected: 0,
pending: 0,
needsAuth: 0,
failed: 0
};
const byState = { connected: 0, pending: 0, needsAuth: 0, failed: 0 }
for (const s of servers) {
if (s.type === 'connected') byState.connected++;else if (s.type === 'pending') byState.pending++;else if (s.type === 'needs-auth') byState.needsAuth++;else byState.failed++;
if (s.type === 'connected') byState.connected++
else if (s.type === 'pending') byState.pending++
else if (s.type === 'needs-auth') byState.needsAuth++
else byState.failed++
}
const parts: string[] = [];
if (byState.connected) parts.push(color('success', theme)(`${byState.connected} connected`));
if (byState.needsAuth) parts.push(color('warning', theme)(`${byState.needsAuth} need auth`));
if (byState.pending) parts.push(color('inactive', theme)(`${byState.pending} pending`));
if (byState.failed) parts.push(color('error', theme)(`${byState.failed} failed`));
return [{
label: 'MCP servers',
value: `${parts.join(', ')} ${color('inactive', theme)('· /mcp')}`
}];
const parts: string[] = []
if (byState.connected)
parts.push(color('success', theme)(`${byState.connected} connected`))
if (byState.needsAuth)
parts.push(color('warning', theme)(`${byState.needsAuth} need auth`))
if (byState.pending)
parts.push(color('inactive', theme)(`${byState.pending} pending`))
if (byState.failed)
parts.push(color('error', theme)(`${byState.failed} failed`))
return [
{
label: 'MCP servers',
value: `${parts.join(', ')} ${color('inactive', theme)('· /mcp')}`,
},
]
}
export async function buildMemoryDiagnostics(): Promise<Diagnostic[]> {
const files = await getMemoryFiles();
const largeFiles = getLargeMemoryFiles(files);
const diagnostics: Diagnostic[] = [];
const files = await getMemoryFiles()
const largeFiles = getLargeMemoryFiles(files)
const diagnostics: Diagnostic[] = []
largeFiles.forEach(file => {
const displayPath = getDisplayPath(file.path);
diagnostics.push(`Large ${displayPath} will impact performance (${formatNumber(file.content.length)} chars > ${formatNumber(MAX_MEMORY_CHARACTER_COUNT)})`);
});
return diagnostics;
const displayPath = getDisplayPath(file.path)
diagnostics.push(
`Large ${displayPath} will impact performance (${formatNumber(file.content.length)} chars > ${formatNumber(MAX_MEMORY_CHARACTER_COUNT)})`,
)
})
return diagnostics
}
export function buildSettingSourcesProperties(): Property[] {
const enabledSources = getEnabledSettingSources();
const enabledSources = getEnabledSettingSources()
// Filter to only sources that actually have settings loaded
const sourcesWithSettings = enabledSources.filter(source => {
const settings = getSettingsForSource(source);
return settings !== null && Object.keys(settings).length > 0;
});
const settings = getSettingsForSource(source)
return settings !== null && Object.keys(settings).length > 0
})
// Map internal names to user-friendly names
// For policySettings, distinguish between remote and local (or skip if neither exists)
const sourceNames = sourcesWithSettings.map(source => {
if (source === 'policySettings') {
const origin = getPolicySettingsOrigin();
if (origin === null) {
return null; // Skip - no policy settings exist
}
switch (origin) {
case 'remote':
return 'Enterprise managed settings (remote)';
case 'plist':
return 'Enterprise managed settings (plist)';
case 'hklm':
return 'Enterprise managed settings (HKLM)';
case 'file':
{
const {
hasBase,
hasDropIns
} = getManagedFileSettingsPresence();
const sourceNames = sourcesWithSettings
.map(source => {
if (source === 'policySettings') {
const origin = getPolicySettingsOrigin()
if (origin === null) {
return null // Skip - no policy settings exist
}
switch (origin) {
case 'remote':
return 'Enterprise managed settings (remote)'
case 'plist':
return 'Enterprise managed settings (plist)'
case 'hklm':
return 'Enterprise managed settings (HKLM)'
case 'file': {
const { hasBase, hasDropIns } = getManagedFileSettingsPresence()
if (hasBase && hasDropIns) {
return 'Enterprise managed settings (file + drop-ins)';
return 'Enterprise managed settings (file + drop-ins)'
}
if (hasDropIns) {
return 'Enterprise managed settings (drop-ins)';
return 'Enterprise managed settings (drop-ins)'
}
return 'Enterprise managed settings (file)';
return 'Enterprise managed settings (file)'
}
case 'hkcu':
return 'Enterprise managed settings (HKCU)';
case 'hkcu':
return 'Enterprise managed settings (HKCU)'
}
}
}
return getSettingSourceDisplayNameCapitalized(source);
}).filter((name): name is string => name !== null);
return [{
label: 'Setting sources',
value: sourceNames
}];
return getSettingSourceDisplayNameCapitalized(source)
})
.filter((name): name is string => name !== null)
return [
{
label: 'Setting sources',
value: sourceNames,
},
]
}
export async function buildInstallationDiagnostics(): Promise<Diagnostic[]> {
const installWarnings = await checkInstall();
return installWarnings.map(warning => warning.message);
const installWarnings = await checkInstall()
return installWarnings.map(warning => warning.message)
}
export async function buildInstallationHealthDiagnostics(): Promise<Diagnostic[]> {
const diagnostic = await getDoctorDiagnostic();
const items: Diagnostic[] = [];
const {
errors: validationErrors
} = getSettingsWithAllErrors();
export async function buildInstallationHealthDiagnostics(): Promise<
Diagnostic[]
> {
const diagnostic = await getDoctorDiagnostic()
const items: Diagnostic[] = []
const { errors: validationErrors } = getSettingsWithAllErrors()
if (validationErrors.length > 0) {
const invalidFiles = Array.from(new Set(validationErrors.map(error => error.file)));
const fileList = invalidFiles.join(', ');
items.push(`Found invalid settings files: ${fileList}. They will be ignored.`);
const invalidFiles = Array.from(
new Set(validationErrors.map(error => error.file)),
)
const fileList = invalidFiles.join(', ')
items.push(
`Found invalid settings files: ${fileList}. They will be ignored.`,
)
}
// Add warnings from doctor diagnostic (includes leftover installations, config mismatches, etc.)
diagnostic.warnings.forEach(warning => {
items.push(warning.issue);
});
items.push(warning.issue)
})
if (diagnostic.hasUpdatePermissions === false) {
items.push('No write permissions for auto-updates (requires sudo)');
items.push('No write permissions for auto-updates (requires sudo)')
}
return items;
return items
}
export function buildAccountProperties(): Property[] {
const accountInfo = getAccountInformation();
const accountInfo = getAccountInformation()
if (!accountInfo) {
return [];
return []
}
const properties: Property[] = [];
const properties: Property[] = []
if (accountInfo.subscription) {
properties.push({
label: 'Login method',
value: `${accountInfo.subscription} Account`
});
value: `${accountInfo.subscription} Account`,
})
}
if (accountInfo.tokenSource) {
properties.push({
label: 'Auth token',
value: accountInfo.tokenSource
});
value: accountInfo.tokenSource,
})
}
if (accountInfo.apiKeySource) {
properties.push({
label: 'API key',
value: accountInfo.apiKeySource
});
value: accountInfo.apiKeySource,
})
}
// Hide sensitive account info in demo mode
if (accountInfo.organization && !process.env.IS_DEMO) {
properties.push({
label: 'Organization',
value: accountInfo.organization
});
value: accountInfo.organization,
})
}
if (accountInfo.email && !process.env.IS_DEMO) {
properties.push({
label: 'Email',
value: accountInfo.email
});
value: accountInfo.email,
})
}
return properties;
return properties
}
export function buildAPIProviderProperties(): Property[] {
const apiProvider = getAPIProvider();
const properties: Property[] = [];
const apiProvider = getAPIProvider()
const properties: Property[] = []
if (apiProvider !== 'firstParty') {
const providerLabel = {
bedrock: 'AWS Bedrock',
vertex: 'Google Vertex AI',
foundry: 'Microsoft Foundry'
}[apiProvider];
foundry: 'Microsoft Foundry',
}[apiProvider]
properties.push({
label: 'API provider',
value: providerLabel
});
value: providerLabel,
})
}
if (apiProvider === 'firstParty') {
const anthropicBaseUrl = process.env.ANTHROPIC_BASE_URL;
const anthropicBaseUrl = process.env.ANTHROPIC_BASE_URL
if (anthropicBaseUrl) {
properties.push({
label: 'Anthropic base URL',
value: anthropicBaseUrl
});
value: anthropicBaseUrl,
})
}
} else if (apiProvider === 'bedrock') {
const bedrockBaseUrl = process.env.BEDROCK_BASE_URL;
const bedrockBaseUrl = process.env.BEDROCK_BASE_URL
if (bedrockBaseUrl) {
properties.push({
label: 'Bedrock base URL',
value: bedrockBaseUrl
});
value: bedrockBaseUrl,
})
}
properties.push({
label: 'AWS region',
value: getAWSRegion()
});
value: getAWSRegion(),
})
if (isEnvTruthy(process.env.CLAUDE_CODE_SKIP_BEDROCK_AUTH)) {
properties.push({
value: 'AWS auth skipped'
});
value: 'AWS auth skipped',
})
}
} else if (apiProvider === 'vertex') {
const vertexBaseUrl = process.env.VERTEX_BASE_URL;
const vertexBaseUrl = process.env.VERTEX_BASE_URL
if (vertexBaseUrl) {
properties.push({
label: 'Vertex base URL',
value: vertexBaseUrl
});
value: vertexBaseUrl,
})
}
const gcpProject = process.env.ANTHROPIC_VERTEX_PROJECT_ID;
const gcpProject = process.env.ANTHROPIC_VERTEX_PROJECT_ID
if (gcpProject) {
properties.push({
label: 'GCP project',
value: gcpProject
});
value: gcpProject,
})
}
properties.push({
label: 'Default region',
value: getDefaultVertexRegion()
});
value: getDefaultVertexRegion(),
})
if (isEnvTruthy(process.env.CLAUDE_CODE_SKIP_VERTEX_AUTH)) {
properties.push({
value: 'GCP auth skipped'
});
value: 'GCP auth skipped',
})
}
} else if (apiProvider === 'foundry') {
const foundryBaseUrl = process.env.ANTHROPIC_FOUNDRY_BASE_URL;
const foundryBaseUrl = process.env.ANTHROPIC_FOUNDRY_BASE_URL
if (foundryBaseUrl) {
properties.push({
label: 'Microsoft Foundry base URL',
value: foundryBaseUrl
});
value: foundryBaseUrl,
})
}
const foundryResource = process.env.ANTHROPIC_FOUNDRY_RESOURCE;
const foundryResource = process.env.ANTHROPIC_FOUNDRY_RESOURCE
if (foundryResource) {
properties.push({
label: 'Microsoft Foundry resource',
value: foundryResource
});
value: foundryResource,
})
}
if (isEnvTruthy(process.env.CLAUDE_CODE_SKIP_FOUNDRY_AUTH)) {
properties.push({
value: 'Microsoft Foundry auth skipped'
});
value: 'Microsoft Foundry auth skipped',
})
}
}
const proxyUrl = getProxyUrl();
const proxyUrl = getProxyUrl()
if (proxyUrl) {
properties.push({
label: 'Proxy',
value: proxyUrl
});
value: proxyUrl,
})
}
const mtlsConfig = getMTLSConfig();
const mtlsConfig = getMTLSConfig()
if (process.env.NODE_EXTRA_CA_CERTS) {
properties.push({
label: 'Additional CA cert(s)',
value: process.env.NODE_EXTRA_CA_CERTS
});
value: process.env.NODE_EXTRA_CA_CERTS,
})
}
if (mtlsConfig) {
if (mtlsConfig.cert && process.env.CLAUDE_CODE_CLIENT_CERT) {
properties.push({
label: 'mTLS client cert',
value: process.env.CLAUDE_CODE_CLIENT_CERT
});
value: process.env.CLAUDE_CODE_CLIENT_CERT,
})
}
if (mtlsConfig.key && process.env.CLAUDE_CODE_CLIENT_KEY) {
properties.push({
label: 'mTLS client key',
value: process.env.CLAUDE_CODE_CLIENT_KEY
});
value: process.env.CLAUDE_CODE_CLIENT_KEY,
})
}
}
return properties;
return properties
}
export function getModelDisplayLabel(mainLoopModel: string | null): string {
let modelLabel = modelDisplayString(mainLoopModel);
let modelLabel = modelDisplayString(mainLoopModel)
if (mainLoopModel === null && isClaudeAISubscriber()) {
const description = getClaudeAiUserDefaultModelDescription();
modelLabel = `${chalk.bold('Default')} ${description}`;
const description = getClaudeAiUserDefaultModelDescription()
modelLabel = `${chalk.bold('Default')} ${description}`
}
return modelLabel;
return modelLabel
}

View File

@@ -1,31 +1,49 @@
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
import { Box, Text } from '../ink.js';
import * as React from 'react';
import { getLargeMemoryFiles, MAX_MEMORY_CHARACTER_COUNT, type MemoryFileInfo } from './claudemd.js';
import figures from 'figures';
import { getCwd } from './cwd.js';
import { relative } from 'path';
import { formatNumber } from './format.js';
import type { getGlobalConfig } from './config.js';
import { getAnthropicApiKeyWithSource, getApiKeyFromConfigOrMacOSKeychain, getAuthTokenSource, isClaudeAISubscriber } from './auth.js';
import type { AgentDefinitionsResult } from '../tools/AgentTool/loadAgentsDir.js';
import { getAgentDescriptionsTotalTokens, AGENT_DESCRIPTIONS_THRESHOLD } from './statusNoticeHelpers.js';
import { isSupportedJetBrainsTerminal, toIDEDisplayName, getTerminalIdeType } from './ide.js';
import { isJetBrainsPluginInstalledCachedSync } from './jetbrains.js';
import { Box, Text } from '../ink.js'
import * as React from 'react'
import {
getLargeMemoryFiles,
MAX_MEMORY_CHARACTER_COUNT,
type MemoryFileInfo,
} from './claudemd.js'
import figures from 'figures'
import { getCwd } from './cwd.js'
import { relative } from 'path'
import { formatNumber } from './format.js'
import type { getGlobalConfig } from './config.js'
import {
getAnthropicApiKeyWithSource,
getApiKeyFromConfigOrMacOSKeychain,
getAuthTokenSource,
isClaudeAISubscriber,
} from './auth.js'
import type { AgentDefinitionsResult } from '../tools/AgentTool/loadAgentsDir.js'
import {
getAgentDescriptionsTotalTokens,
AGENT_DESCRIPTIONS_THRESHOLD,
} from './statusNoticeHelpers.js'
import {
isSupportedJetBrainsTerminal,
toIDEDisplayName,
getTerminalIdeType,
} from './ide.js'
import { isJetBrainsPluginInstalledCachedSync } from './jetbrains.js'
// Types
export type StatusNoticeType = 'warning' | 'info';
export type StatusNoticeType = 'warning' | 'info'
export type StatusNoticeContext = {
config: ReturnType<typeof getGlobalConfig>;
agentDefinitions?: AgentDefinitionsResult;
memoryFiles: MemoryFileInfo[];
};
config: ReturnType<typeof getGlobalConfig>
agentDefinitions?: AgentDefinitionsResult
memoryFiles: MemoryFileInfo[]
}
export type StatusNoticeDefinition = {
id: string;
type: StatusNoticeType;
isActive: (context: StatusNoticeContext) => boolean;
render: (context: StatusNoticeContext) => React.ReactNode;
};
id: string
type: StatusNoticeType
isActive: (context: StatusNoticeContext) => boolean
render: (context: StatusNoticeContext) => React.ReactNode
}
// Individual notice definitions
const largeMemoryFilesNotice: StatusNoticeDefinition = {
@@ -33,11 +51,16 @@ const largeMemoryFilesNotice: StatusNoticeDefinition = {
type: 'warning',
isActive: ctx => getLargeMemoryFiles(ctx.memoryFiles).length > 0,
render: ctx => {
const largeMemoryFiles = getLargeMemoryFiles(ctx.memoryFiles);
return <>
const largeMemoryFiles = getLargeMemoryFiles(ctx.memoryFiles)
return (
<>
{largeMemoryFiles.map(file => {
const displayPath = file.path.startsWith(getCwd()) ? relative(getCwd(), file.path) : file.path;
return <Box key={file.path} flexDirection="row">
const displayPath = file.path.startsWith(getCwd())
? relative(getCwd(), file.path)
: file.path
return (
<Box key={file.path} flexDirection="row">
<Text color="warning">{figures.warning}</Text>
<Text color="warning">
Large <Text bold>{displayPath}</Text> will impact performance (
@@ -45,76 +68,92 @@ const largeMemoryFilesNotice: StatusNoticeDefinition = {
{formatNumber(MAX_MEMORY_CHARACTER_COUNT)})
<Text dimColor> · /memory to edit</Text>
</Text>
</Box>;
})}
</>;
}
};
</Box>
)
})}
</>
)
},
}
const claudeAiSubscriberExternalTokenNotice: StatusNoticeDefinition = {
id: 'claude-ai-external-token',
type: 'warning',
isActive: () => {
const authTokenInfo = getAuthTokenSource();
return isClaudeAISubscriber() && (authTokenInfo.source === 'ANTHROPIC_AUTH_TOKEN' || authTokenInfo.source === 'apiKeyHelper');
const authTokenInfo = getAuthTokenSource()
return (
isClaudeAISubscriber() &&
(authTokenInfo.source === 'ANTHROPIC_AUTH_TOKEN' ||
authTokenInfo.source === 'apiKeyHelper')
)
},
render: () => {
const authTokenInfo = getAuthTokenSource();
return <Box flexDirection="row" marginTop={1}>
const authTokenInfo = getAuthTokenSource()
return (
<Box flexDirection="row" marginTop={1}>
<Text color="warning">{figures.warning}</Text>
<Text color="warning">
Auth conflict: Using {authTokenInfo.source} instead of Claude account
subscription token. Either unset {authTokenInfo.source}, or run
`claude /logout`.
</Text>
</Box>;
}
};
</Box>
)
},
}
const apiKeyConflictNotice: StatusNoticeDefinition = {
id: 'api-key-conflict',
type: 'warning',
isActive: () => {
const {
source: apiKeySource
} = getAnthropicApiKeyWithSource({
skipRetrievingKeyFromApiKeyHelper: true
});
return !!getApiKeyFromConfigOrMacOSKeychain() && (apiKeySource === 'ANTHROPIC_API_KEY' || apiKeySource === 'apiKeyHelper');
const { source: apiKeySource } = getAnthropicApiKeyWithSource({
skipRetrievingKeyFromApiKeyHelper: true,
})
return (
!!getApiKeyFromConfigOrMacOSKeychain() &&
(apiKeySource === 'ANTHROPIC_API_KEY' || apiKeySource === 'apiKeyHelper')
)
},
render: () => {
const {
source: apiKeySource
} = getAnthropicApiKeyWithSource({
skipRetrievingKeyFromApiKeyHelper: true
});
return <Box flexDirection="row" marginTop={1}>
const { source: apiKeySource } = getAnthropicApiKeyWithSource({
skipRetrievingKeyFromApiKeyHelper: true,
})
return (
<Box flexDirection="row" marginTop={1}>
<Text color="warning">{figures.warning}</Text>
<Text color="warning">
Auth conflict: Using {apiKeySource} instead of Anthropic Console key.
Either unset {apiKeySource}, or run `claude /logout`.
</Text>
</Box>;
}
};
</Box>
)
},
}
const bothAuthMethodsNotice: StatusNoticeDefinition = {
id: 'both-auth-methods',
type: 'warning',
isActive: () => {
const {
source: apiKeySource
} = getAnthropicApiKeyWithSource({
skipRetrievingKeyFromApiKeyHelper: true
});
const authTokenInfo = getAuthTokenSource();
return apiKeySource !== 'none' && authTokenInfo.source !== 'none' && !(apiKeySource === 'apiKeyHelper' && authTokenInfo.source === 'apiKeyHelper');
const { source: apiKeySource } = getAnthropicApiKeyWithSource({
skipRetrievingKeyFromApiKeyHelper: true,
})
const authTokenInfo = getAuthTokenSource()
return (
apiKeySource !== 'none' &&
authTokenInfo.source !== 'none' &&
!(
apiKeySource === 'apiKeyHelper' &&
authTokenInfo.source === 'apiKeyHelper'
)
)
},
render: () => {
const {
source: apiKeySource
} = getAnthropicApiKeyWithSource({
skipRetrievingKeyFromApiKeyHelper: true
});
const authTokenInfo = getAuthTokenSource();
return <Box flexDirection="column" marginTop={1}>
const { source: apiKeySource } = getAnthropicApiKeyWithSource({
skipRetrievingKeyFromApiKeyHelper: true,
})
const authTokenInfo = getAuthTokenSource()
return (
<Box flexDirection="column" marginTop={1}>
<Box flexDirection="row">
<Text color="warning">{figures.warning}</Text>
<Text color="warning">
@@ -125,28 +164,43 @@ const bothAuthMethodsNotice: StatusNoticeDefinition = {
<Box flexDirection="column" marginLeft={3}>
<Text color="warning">
· Trying to use{' '}
{authTokenInfo.source === 'claude.ai' ? 'claude.ai' : authTokenInfo.source}
{authTokenInfo.source === 'claude.ai'
? 'claude.ai'
: authTokenInfo.source}
?{' '}
{apiKeySource === 'ANTHROPIC_API_KEY' ? 'Unset the ANTHROPIC_API_KEY environment variable, or claude /logout then say "No" to the API key approval before login.' : apiKeySource === 'apiKeyHelper' ? 'Unset the apiKeyHelper setting.' : 'claude /logout'}
{apiKeySource === 'ANTHROPIC_API_KEY'
? 'Unset the ANTHROPIC_API_KEY environment variable, or claude /logout then say "No" to the API key approval before login.'
: apiKeySource === 'apiKeyHelper'
? 'Unset the apiKeyHelper setting.'
: 'claude /logout'}
</Text>
<Text color="warning">
· Trying to use {apiKeySource}?{' '}
{authTokenInfo.source === 'claude.ai' ? 'claude /logout to sign out of claude.ai.' : `Unset the ${authTokenInfo.source} environment variable.`}
{authTokenInfo.source === 'claude.ai'
? 'claude /logout to sign out of claude.ai.'
: `Unset the ${authTokenInfo.source} environment variable.`}
</Text>
</Box>
</Box>;
}
};
</Box>
)
},
}
const largeAgentDescriptionsNotice: StatusNoticeDefinition = {
id: 'large-agent-descriptions',
type: 'warning',
isActive: context => {
const totalTokens = getAgentDescriptionsTotalTokens(context.agentDefinitions);
return totalTokens > AGENT_DESCRIPTIONS_THRESHOLD;
const totalTokens = getAgentDescriptionsTotalTokens(
context.agentDefinitions,
)
return totalTokens > AGENT_DESCRIPTIONS_THRESHOLD
},
render: context => {
const totalTokens = getAgentDescriptionsTotalTokens(context.agentDefinitions);
return <Box flexDirection="row">
const totalTokens = getAgentDescriptionsTotalTokens(
context.agentDefinitions,
)
return (
<Box flexDirection="row">
<Text color="warning">{figures.warning}</Text>
<Text color="warning">
Large cumulative agent descriptions will impact performance (~
@@ -154,44 +208,58 @@ const largeAgentDescriptionsNotice: StatusNoticeDefinition = {
{formatNumber(AGENT_DESCRIPTIONS_THRESHOLD)})
<Text dimColor> · /agents to manage</Text>
</Text>
</Box>;
}
};
</Box>
)
},
}
const jetbrainsPluginNotice: StatusNoticeDefinition = {
id: 'jetbrains-plugin-install',
type: 'info',
isActive: context => {
// Only show if running in JetBrains built-in terminal
if (!isSupportedJetBrainsTerminal()) {
return false;
return false
}
// Don't show if auto-install is disabled
const shouldAutoInstall = context.config.autoInstallIdeExtension ?? true;
const shouldAutoInstall = context.config.autoInstallIdeExtension ?? true
if (!shouldAutoInstall) {
return false;
return false
}
// Check if plugin is already installed (cached to avoid repeated filesystem checks)
const ideType = getTerminalIdeType();
return ideType !== null && !isJetBrainsPluginInstalledCachedSync(ideType);
const ideType = getTerminalIdeType()
return ideType !== null && !isJetBrainsPluginInstalledCachedSync(ideType)
},
render: () => {
const ideType = getTerminalIdeType();
const ideName = toIDEDisplayName(ideType);
return <Box flexDirection="row" gap={1} marginLeft={1}>
const ideType = getTerminalIdeType()
const ideName = toIDEDisplayName(ideType)
return (
<Box flexDirection="row" gap={1} marginLeft={1}>
<Text color="ide">{figures.arrowUp}</Text>
<Text>
Install the <Text color="ide">{ideName}</Text> plugin from the
JetBrains Marketplace:{' '}
<Text bold>https://docs.claude.com/s/claude-code-jetbrains</Text>
</Text>
</Box>;
}
};
</Box>
)
},
}
// All notice definitions
export const statusNoticeDefinitions: StatusNoticeDefinition[] = [largeMemoryFilesNotice, largeAgentDescriptionsNotice, claudeAiSubscriberExternalTokenNotice, apiKeyConflictNotice, bothAuthMethodsNotice, jetbrainsPluginNotice];
export const statusNoticeDefinitions: StatusNoticeDefinition[] = [
largeMemoryFilesNotice,
largeAgentDescriptionsNotice,
claudeAiSubscriberExternalTokenNotice,
apiKeyConflictNotice,
bothAuthMethodsNotice,
jetbrainsPluginNotice,
]
// Helper functions for external use
export function getActiveNotices(context: StatusNoticeContext): StatusNoticeDefinition[] {
return statusNoticeDefinitions.filter(notice => notice.isActive(context));
export function getActiveNotices(
context: StatusNoticeContext,
): StatusNoticeDefinition[] {
return statusNoticeDefinitions.filter(notice => notice.isActive(context))
}

View File

@@ -1,379 +1,377 @@
import { c as _c } from "react/compiler-runtime";
import React, { useCallback, useEffect, useState } from 'react';
import { type OptionWithDescription, Select } from '../../components/CustomSelect/index.js';
import { Pane } from '../../components/design-system/Pane.js';
import { Spinner } from '../../components/Spinner.js';
import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js';
import React, { useCallback, useEffect, useState } from 'react'
import {
type OptionWithDescription,
Select,
} from '../../components/CustomSelect/index.js'
import { Pane } from '../../components/design-system/Pane.js'
import { Spinner } from '../../components/Spinner.js'
import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- enter to proceed through setup steps
import { Box, Text, useInput } from '../../ink.js';
import { useKeybinding } from '../../keybindings/useKeybinding.js';
import { detectPythonPackageManager, getPythonApiInstructions, installIt2, markIt2SetupComplete, type PythonPackageManager, setPreferTmuxOverIterm2, verifyIt2Setup } from './backends/it2Setup.js';
type SetupStep = 'initial' | 'installing' | 'install-failed' | 'verify-api' | 'api-instructions' | 'verifying' | 'success' | 'failed';
import { Box, Text, useInput } from '../../ink.js'
import { useKeybinding } from '../../keybindings/useKeybinding.js'
import {
detectPythonPackageManager,
getPythonApiInstructions,
installIt2,
markIt2SetupComplete,
type PythonPackageManager,
setPreferTmuxOverIterm2,
verifyIt2Setup,
} from './backends/it2Setup.js'
type SetupStep =
| 'initial'
| 'installing'
| 'install-failed'
| 'verify-api'
| 'api-instructions'
| 'verifying'
| 'success'
| 'failed'
type Props = {
onDone: (result: 'installed' | 'use-tmux' | 'cancelled') => void;
tmuxAvailable: boolean;
};
export function It2SetupPrompt(t0) {
const $ = _c(44);
const {
onDone,
tmuxAvailable
} = t0;
const [step, setStep] = useState("initial");
const [packageManager, setPackageManager] = useState(null);
const [error, setError] = useState(null);
const exitState = useExitOnCtrlCDWithKeybindings();
let t1;
let t2;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t1 = () => {
detectPythonPackageManager().then(pm => {
setPackageManager(pm);
});
};
t2 = [];
$[0] = t1;
$[1] = t2;
} else {
t1 = $[0];
t2 = $[1];
}
useEffect(t1, t2);
let t3;
if ($[2] !== onDone) {
t3 = () => {
onDone("cancelled");
};
$[2] = onDone;
$[3] = t3;
} else {
t3 = $[3];
}
const handleCancel = t3;
const t4 = step !== "installing" && step !== "verifying";
let t5;
if ($[4] !== t4) {
t5 = {
context: "Confirmation",
isActive: t4
};
$[4] = t4;
$[5] = t5;
} else {
t5 = $[5];
}
useKeybinding("confirm:no", handleCancel, t5);
let t6;
if ($[6] !== onDone || $[7] !== step) {
t6 = (_input, key) => {
if (step === "api-instructions" && key.return) {
setStep("verifying");
verifyIt2Setup().then(result => {
if (result.success) {
markIt2SetupComplete();
setStep("success");
setTimeout(onDone, 1500, "installed" as const);
} else {
setError(result.error || "Verification failed");
setStep("failed");
}
});
}
};
$[6] = onDone;
$[7] = step;
$[8] = t6;
} else {
t6 = $[8];
}
useInput(t6);
let t7;
if ($[9] !== packageManager) {
t7 = async function handleInstall() {
if (!packageManager) {
setError("No Python package manager found (uvx, pipx, or pip)");
setStep("failed");
return;
}
setStep("installing");
const result_0 = await installIt2(packageManager);
if (result_0.success) {
setStep("api-instructions");
} else {
setError(result_0.error || "Installation failed");
setStep("install-failed");
}
};
$[9] = packageManager;
$[10] = t7;
} else {
t7 = $[10];
}
const handleInstall = t7;
let t8;
if ($[11] !== onDone) {
t8 = function handleUseTmux() {
setPreferTmuxOverIterm2(true);
onDone("use-tmux");
};
$[11] = onDone;
$[12] = t8;
} else {
t8 = $[12];
}
const handleUseTmux = t8;
let T0;
let T1;
let t10;
let t11;
let t12;
let t13;
let t14;
let t9;
if ($[13] !== error || $[14] !== handleInstall || $[15] !== handleUseTmux || $[16] !== onDone || $[17] !== packageManager || $[18] !== step || $[19] !== tmuxAvailable) {
const renderContent = () => {
switch (step) {
case "initial":
{
return renderInitialPrompt();
}
case "installing":
{
return renderInstalling();
}
case "install-failed":
{
return renderInstallFailed();
}
case "api-instructions":
{
return renderApiInstructions();
}
case "verifying":
{
return renderVerifying();
}
case "success":
{
return renderSuccess();
}
case "failed":
{
return renderFailed();
}
default:
{
return null;
}
}
};
function renderInitialPrompt() {
const options = [{
label: "Install it2 now",
value: "install",
description: packageManager ? `Uses ${packageManager} to install the it2 CLI tool` : "Requires Python (uvx, pipx, or pip)"
}];
if (tmuxAvailable) {
options.push({
label: "Use tmux instead",
value: "tmux",
description: "Opens teammates in a separate tmux session"
});
}
options.push({
label: "Cancel",
value: "cancel",
description: "Skip teammate spawning for now"
});
return <Box flexDirection="column" gap={1}><Text>To use native iTerm2 split panes for teammates, you need the{" "}<Text bold={true}>it2</Text> CLI tool.</Text><Text dimColor={true}>This enables teammates to appear as split panes within your current window.</Text><Box marginTop={1}><Select options={options} onChange={value => {
bb61: switch (value) {
case "install":
{
handleInstall();
break bb61;
}
case "tmux":
{
handleUseTmux();
break bb61;
}
case "cancel":
{
onDone("cancelled");
}
}
}} onCancel={() => onDone("cancelled")} /></Box></Box>;
onDone: (result: 'installed' | 'use-tmux' | 'cancelled') => void
tmuxAvailable: boolean
}
export function It2SetupPrompt({
onDone,
tmuxAvailable,
}: Props): React.ReactNode {
const [step, setStep] = useState<SetupStep>('initial')
const [packageManager, setPackageManager] =
useState<PythonPackageManager | null>(null)
const [error, setError] = useState<string | null>(null)
const exitState = useExitOnCtrlCDWithKeybindings()
// Detect package manager on mount
useEffect(() => {
void detectPythonPackageManager().then(pm => {
setPackageManager(pm)
})
}, [])
const handleCancel = useCallback(() => {
onDone('cancelled')
}, [onDone])
useKeybinding('confirm:no', handleCancel, {
context: 'Confirmation',
isActive: step !== 'installing' && step !== 'verifying',
})
// Handle keyboard input for verification step
useInput((_input, key) => {
if (step === 'api-instructions' && key.return) {
setStep('verifying')
void verifyIt2Setup().then(result => {
if (result.success) {
markIt2SetupComplete()
setStep('success')
setTimeout(onDone, 1500, 'installed' as const)
} else {
setError(result.error || 'Verification failed')
setStep('failed')
}
})
}
function renderInstalling() {
return <Box flexDirection="column" gap={1}><Box><Spinner /><Text> Installing it2 using {packageManager}</Text></Box><Text dimColor={true}>This may take a moment.</Text></Box>;
})
// Handle installation
async function handleInstall(): Promise<void> {
if (!packageManager) {
setError('No Python package manager found (uvx, pipx, or pip)')
setStep('failed')
return
}
function renderInstallFailed() {
const options_0 = [{
label: "Try again",
value: "retry",
description: "Retry the installation"
}];
if (tmuxAvailable) {
options_0.push({
label: "Use tmux instead",
value: "tmux",
description: "Falls back to tmux for teammate panes"
});
}
options_0.push({
label: "Cancel",
value: "cancel",
description: "Skip teammate spawning for now"
});
return <Box flexDirection="column" gap={1}><Text color="error">Installation failed</Text>{error && <Text dimColor={true}>{error}</Text>}<Text dimColor={true}>You can try installing manually:{" "}{packageManager === "uvx" ? "uv tool install it2" : packageManager === "pipx" ? "pipx install it2" : "pip install --user it2"}</Text><Box marginTop={1}><Select options={options_0} onChange={value_0 => {
bb89: switch (value_0) {
case "retry":
{
handleInstall();
break bb89;
}
case "tmux":
{
handleUseTmux();
break bb89;
}
case "cancel":
{
onDone("cancelled");
}
}
}} onCancel={() => onDone("cancelled")} /></Box></Box>;
}
function renderApiInstructions() {
const instructions = getPythonApiInstructions();
return <Box flexDirection="column" gap={1}><Text color="success"> it2 installed successfully</Text><Box flexDirection="column" marginTop={1}>{instructions.map(_temp)}</Box><Box marginTop={1}><Text dimColor={true}>Press Enter when ready to verify</Text></Box></Box>;
}
function renderVerifying() {
return <Box><Spinner /><Text> Verifying it2 can communicate with iTerm2</Text></Box>;
}
function renderSuccess() {
return <Box flexDirection="column"><Text color="success"> iTerm2 split pane support is ready</Text><Text dimColor={true}>Teammates will now appear as split panes.</Text></Box>;
}
function renderFailed() {
const options_1 = [{
label: "Try again",
value: "retry",
description: "Verify the connection again"
}];
if (tmuxAvailable) {
options_1.push({
label: "Use tmux instead",
value: "tmux",
description: "Falls back to tmux for teammate panes"
});
}
options_1.push({
label: "Cancel",
value: "cancel",
description: "Skip teammate spawning for now"
});
return <Box flexDirection="column" gap={1}><Text color="error">Verification failed</Text>{error && <Text dimColor={true}>{error}</Text>}<Text>Make sure:</Text><Box flexDirection="column" paddingLeft={2}><Text>· Python API is enabled in iTerm2 preferences</Text><Text>· You may need to restart iTerm2 after enabling</Text></Box><Box marginTop={1}><Select options={options_1} onChange={value_1 => {
bb115: switch (value_1) {
case "retry":
{
setStep("verifying");
verifyIt2Setup().then(result_1 => {
if (result_1.success) {
markIt2SetupComplete();
setStep("success");
setTimeout(onDone, 1500, "installed" as const);
} else {
setError(result_1.error || "Verification failed");
setStep("failed");
}
});
break bb115;
}
case "tmux":
{
handleUseTmux();
break bb115;
}
case "cancel":
{
onDone("cancelled");
}
}
}} onCancel={() => onDone("cancelled")} /></Box></Box>;
}
T1 = Pane;
t14 = "permission";
T0 = Box;
t9 = "column";
t10 = 1;
t11 = 1;
if ($[28] === Symbol.for("react.memo_cache_sentinel")) {
t12 = <Text bold={true} color="permission">iTerm2 Split Pane Setup</Text>;
$[28] = t12;
setStep('installing')
const result = await installIt2(packageManager)
if (result.success) {
// Show Python API instructions
setStep('api-instructions')
} else {
t12 = $[28];
setError(result.error || 'Installation failed')
setStep('install-failed')
}
t13 = renderContent();
$[13] = error;
$[14] = handleInstall;
$[15] = handleUseTmux;
$[16] = onDone;
$[17] = packageManager;
$[18] = step;
$[19] = tmuxAvailable;
$[20] = T0;
$[21] = T1;
$[22] = t10;
$[23] = t11;
$[24] = t12;
$[25] = t13;
$[26] = t14;
$[27] = t9;
} else {
T0 = $[20];
T1 = $[21];
t10 = $[22];
t11 = $[23];
t12 = $[24];
t13 = $[25];
t14 = $[26];
t9 = $[27];
}
let t15;
if ($[29] !== exitState || $[30] !== step) {
t15 = step !== "installing" && step !== "verifying" && step !== "success" && <Text dimColor={true} italic={true}>{exitState.pending ? <>Press {exitState.keyName} again to exit</> : <>Esc to cancel</>}</Text>;
$[29] = exitState;
$[30] = step;
$[31] = t15;
} else {
t15 = $[31];
// Handle using tmux instead
function handleUseTmux(): void {
setPreferTmuxOverIterm2(true)
onDone('use-tmux')
}
let t16;
if ($[32] !== T0 || $[33] !== t10 || $[34] !== t11 || $[35] !== t12 || $[36] !== t13 || $[37] !== t15 || $[38] !== t9) {
t16 = <T0 flexDirection={t9} gap={t10} paddingBottom={t11}>{t12}{t13}{t15}</T0>;
$[32] = T0;
$[33] = t10;
$[34] = t11;
$[35] = t12;
$[36] = t13;
$[37] = t15;
$[38] = t9;
$[39] = t16;
} else {
t16 = $[39];
// Render based on current step
const renderContent = (): React.ReactNode => {
switch (step) {
case 'initial':
return renderInitialPrompt()
case 'installing':
return renderInstalling()
case 'install-failed':
return renderInstallFailed()
case 'api-instructions':
return renderApiInstructions()
case 'verifying':
return renderVerifying()
case 'success':
return renderSuccess()
case 'failed':
return renderFailed()
default:
return null
}
}
let t17;
if ($[40] !== T1 || $[41] !== t14 || $[42] !== t16) {
t17 = <T1 color={t14}>{t16}</T1>;
$[40] = T1;
$[41] = t14;
$[42] = t16;
$[43] = t17;
} else {
t17 = $[43];
function renderInitialPrompt(): React.ReactNode {
const options: OptionWithDescription<string>[] = [
{
label: 'Install it2 now',
value: 'install',
description: packageManager
? `Uses ${packageManager} to install the it2 CLI tool`
: 'Requires Python (uvx, pipx, or pip)',
},
]
if (tmuxAvailable) {
options.push({
label: 'Use tmux instead',
value: 'tmux',
description: 'Opens teammates in a separate tmux session',
})
}
options.push({
label: 'Cancel',
value: 'cancel',
description: 'Skip teammate spawning for now',
})
return (
<Box flexDirection="column" gap={1}>
<Text>
To use native iTerm2 split panes for teammates, you need the{' '}
<Text bold>it2</Text> CLI tool.
</Text>
<Text dimColor>
This enables teammates to appear as split panes within your current
window.
</Text>
<Box marginTop={1}>
<Select
options={options}
onChange={value => {
switch (value) {
case 'install':
void handleInstall()
break
case 'tmux':
handleUseTmux()
break
case 'cancel':
onDone('cancelled')
break
}
}}
onCancel={() => onDone('cancelled')}
/>
</Box>
</Box>
)
}
return t17;
}
function _temp(line, i) {
return <Text key={i}>{line}</Text>;
function renderInstalling(): React.ReactNode {
return (
<Box flexDirection="column" gap={1}>
<Box>
<Spinner />
<Text> Installing it2 using {packageManager}</Text>
</Box>
<Text dimColor>This may take a moment.</Text>
</Box>
)
}
function renderInstallFailed(): React.ReactNode {
const options: OptionWithDescription<string>[] = [
{
label: 'Try again',
value: 'retry',
description: 'Retry the installation',
},
]
if (tmuxAvailable) {
options.push({
label: 'Use tmux instead',
value: 'tmux',
description: 'Falls back to tmux for teammate panes',
})
}
options.push({
label: 'Cancel',
value: 'cancel',
description: 'Skip teammate spawning for now',
})
return (
<Box flexDirection="column" gap={1}>
<Text color="error">Installation failed</Text>
{error && <Text dimColor>{error}</Text>}
<Text dimColor>
You can try installing manually:{' '}
{packageManager === 'uvx'
? 'uv tool install it2'
: packageManager === 'pipx'
? 'pipx install it2'
: 'pip install --user it2'}
</Text>
<Box marginTop={1}>
<Select
options={options}
onChange={value => {
switch (value) {
case 'retry':
void handleInstall()
break
case 'tmux':
handleUseTmux()
break
case 'cancel':
onDone('cancelled')
break
}
}}
onCancel={() => onDone('cancelled')}
/>
</Box>
</Box>
)
}
function renderApiInstructions(): React.ReactNode {
const instructions = getPythonApiInstructions()
return (
<Box flexDirection="column" gap={1}>
<Text color="success"> it2 installed successfully</Text>
<Box flexDirection="column" marginTop={1}>
{instructions.map((line, i) => (
<Text key={i}>{line}</Text>
))}
</Box>
<Box marginTop={1}>
<Text dimColor>Press Enter when ready to verify</Text>
</Box>
</Box>
)
}
function renderVerifying(): React.ReactNode {
return (
<Box>
<Spinner />
<Text> Verifying it2 can communicate with iTerm2</Text>
</Box>
)
}
function renderSuccess(): React.ReactNode {
return (
<Box flexDirection="column">
<Text color="success"> iTerm2 split pane support is ready</Text>
<Text dimColor>Teammates will now appear as split panes.</Text>
</Box>
)
}
function renderFailed(): React.ReactNode {
const options: OptionWithDescription<string>[] = [
{
label: 'Try again',
value: 'retry',
description: 'Verify the connection again',
},
]
if (tmuxAvailable) {
options.push({
label: 'Use tmux instead',
value: 'tmux',
description: 'Falls back to tmux for teammate panes',
})
}
options.push({
label: 'Cancel',
value: 'cancel',
description: 'Skip teammate spawning for now',
})
return (
<Box flexDirection="column" gap={1}>
<Text color="error">Verification failed</Text>
{error && <Text dimColor>{error}</Text>}
<Text>Make sure:</Text>
<Box flexDirection="column" paddingLeft={2}>
<Text>· Python API is enabled in iTerm2 preferences</Text>
<Text>· You may need to restart iTerm2 after enabling</Text>
</Box>
<Box marginTop={1}>
<Select
options={options}
onChange={value => {
switch (value) {
case 'retry':
setStep('verifying')
void verifyIt2Setup().then(result => {
if (result.success) {
markIt2SetupComplete()
setStep('success')
setTimeout(onDone, 1500, 'installed' as const)
} else {
setError(result.error || 'Verification failed')
setStep('failed')
}
})
break
case 'tmux':
handleUseTmux()
break
case 'cancel':
onDone('cancelled')
break
}
}}
onCancel={() => onDone('cancelled')}
/>
</Box>
</Box>
)
}
return (
<Pane color="permission">
<Box flexDirection="column" gap={1} paddingBottom={1}>
<Text bold color="permission">
iTerm2 Split Pane Setup
</Text>
{renderContent()}
{step !== 'installing' &&
step !== 'verifying' &&
step !== 'success' && (
<Text dimColor italic>
{exitState.pending ? (
<>Press {exitState.keyName} again to exit</>
) : (
<>Esc to cancel</>
)}
</Text>
)}
</Box>
</Pane>
)
}

File diff suppressed because it is too large Load Diff