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

View File

@@ -1,17 +1,19 @@
#!/usr/bin/env bun #!/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 // Bugfix for corepack auto-pinning, which adds yarnpkg to peoples' package.jsons
// eslint-disable-next-line custom-rules/no-top-level-side-effects // 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) // 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 // 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') { 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 // 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 // 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 // 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', 'CLAUDE_CODE_DISABLE_BACKGROUND_TASKS',
]) { ]) {
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level // 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. * Fast-path for --version has zero imports beyond this file.
*/ */
async function main(): Promise<void> { 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 // 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 // MACRO.VERSION is inlined at build time
// biome-ignore lint/suspicious/noConsole:: intentional console output // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(`${MACRO.VERSION} (Claude Code)`); console.log(`${MACRO.VERSION} (Claude Code)`)
return; return
} }
// For all other paths, load the startup profiler // For all other paths, load the startup profiler
const { profileCheckpoint } = await import('../utils/startupProfiler.js'); const { profileCheckpoint } = await import('../utils/startupProfiler.js')
profileCheckpoint('cli_entry'); profileCheckpoint('cli_entry')
// Fast-path for --dump-system-prompt: output the rendered system prompt and exit. // 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. // Used by prompt sensitivity evals to extract the system prompt at a specific commit.
// Ant-only: eliminated from external builds via feature flag. // Ant-only: eliminated from external builds via feature flag.
if (feature('DUMP_SYSTEM_PROMPT') && args[0] === '--dump-system-prompt') { if (feature('DUMP_SYSTEM_PROMPT') && args[0] === '--dump-system-prompt') {
profileCheckpoint('cli_dump_system_prompt_path'); profileCheckpoint('cli_dump_system_prompt_path')
const { enableConfigs } = await import('../utils/config.js'); const { enableConfigs } = await import('../utils/config.js')
enableConfigs(); enableConfigs()
const { getMainLoopModel } = await import('../utils/model/model.js'); const { getMainLoopModel } = await import('../utils/model/model.js')
const modelIdx = args.indexOf('--model'); const modelIdx = args.indexOf('--model')
const model = (modelIdx !== -1 && args[modelIdx + 1]) || getMainLoopModel(); const model = (modelIdx !== -1 && args[modelIdx + 1]) || getMainLoopModel()
const { getSystemPrompt } = await import('../constants/prompts.js'); const { getSystemPrompt } = await import('../constants/prompts.js')
const prompt = await getSystemPrompt([], model); const prompt = await getSystemPrompt([], model)
// biome-ignore lint/suspicious/noConsole:: intentional console output // biome-ignore lint/suspicious/noConsole:: intentional console output
console.log(prompt.join('\n')); console.log(prompt.join('\n'))
return; return
} }
if (process.argv[2] === '--claude-in-chrome-mcp') { if (process.argv[2] === '--claude-in-chrome-mcp') {
profileCheckpoint('cli_claude_in_chrome_mcp_path'); profileCheckpoint('cli_claude_in_chrome_mcp_path')
const { runClaudeInChromeMcpServer } = await import('../utils/claudeInChrome/mcpServer.js'); const { runClaudeInChromeMcpServer } = await import(
await runClaudeInChromeMcpServer(); '../utils/claudeInChrome/mcpServer.js'
return; )
await runClaudeInChromeMcpServer()
return
} else if (process.argv[2] === '--chrome-native-host') { } else if (process.argv[2] === '--chrome-native-host') {
profileCheckpoint('cli_chrome_native_host_path'); profileCheckpoint('cli_chrome_native_host_path')
const { runChromeNativeHost } = await import('../utils/claudeInChrome/chromeNativeHost.js'); const { runChromeNativeHost } = await import(
await runChromeNativeHost(); '../utils/claudeInChrome/chromeNativeHost.js'
return; )
} else if (feature('CHICAGO_MCP') && process.argv[2] === '--computer-use-mcp') { await runChromeNativeHost()
profileCheckpoint('cli_computer_use_mcp_path'); return
const { runComputerUseMcpServer } = await import('../utils/computerUse/mcpServer.js'); } else if (
await runComputerUseMcpServer(); feature('CHICAGO_MCP') &&
return; 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). // 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), // workers are lean. If a worker kind needs configs/auth (assistant will),
// it calls them inside its run() fn. // it calls them inside its run() fn.
if (feature('DAEMON') && args[0] === '--daemon-worker') { if (feature('DAEMON') && args[0] === '--daemon-worker') {
const { runDaemonWorker } = await import('../daemon/workerRegistry.js'); const { runDaemonWorker } = await import('../daemon/workerRegistry.js')
await runDaemonWorker(args[1]); await runDaemonWorker(args[1])
return; return
} }
// Fast-path for `claude remote-control` (also accepts legacy `claude remote` / `claude sync` / `claude bridge`): // 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] === 'sync' ||
args[0] === 'bridge') args[0] === 'bridge')
) { ) {
profileCheckpoint('cli_bridge_path'); profileCheckpoint('cli_bridge_path')
const { enableConfigs } = await import('../utils/config.js'); const { enableConfigs } = await import('../utils/config.js')
enableConfigs(); enableConfigs()
const { getBridgeDisabledReason, checkBridgeMinVersion } = await import('../bridge/bridgeEnabled.js');
const { BRIDGE_LOGIN_ERROR } = await import('../bridge/types.js'); const { getBridgeDisabledReason, checkBridgeMinVersion } = await import(
const { bridgeMain } = await import('../bridge/bridgeMain.js'); '../bridge/bridgeEnabled.js'
const { exitWithError } = await import('../utils/process.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, // Auth check must come before the GrowthBook gate check — without auth,
// GrowthBook has no user context and would return a stale/default false. // GrowthBook has no user context and would return a stale/default false.
// getBridgeDisabledReason awaits GB init, so the returned value is fresh // getBridgeDisabledReason awaits GB init, so the returned value is fresh
// (not the stale disk cache), but init still needs auth headers to work. // (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) { if (!getClaudeAIOAuthTokens()?.accessToken) {
exitWithError(BRIDGE_LOGIN_ERROR); exitWithError(BRIDGE_LOGIN_ERROR)
} }
const disabledReason = await getBridgeDisabledReason(); const disabledReason = await getBridgeDisabledReason()
if (disabledReason) { if (disabledReason) {
exitWithError(`Error: ${disabledReason}`); exitWithError(`Error: ${disabledReason}`)
} }
const versionError = checkBridgeMinVersion(); const versionError = checkBridgeMinVersion()
if (versionError) { if (versionError) {
exitWithError(versionError); exitWithError(versionError)
} }
// Bridge is a remote control feature - check policy limits // Bridge is a remote control feature - check policy limits
const { waitForPolicyLimitsToLoad, isPolicyAllowed } = await import('../services/policyLimits/index.js'); const { waitForPolicyLimitsToLoad, isPolicyAllowed } = await import(
await waitForPolicyLimitsToLoad(); '../services/policyLimits/index.js'
)
await waitForPolicyLimitsToLoad()
if (!isPolicyAllowed('allow_remote_control')) { 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. // Fast-path for `claude daemon [subcommand]`: long-running supervisor.
if (feature('DAEMON') && args[0] === 'daemon') { if (feature('DAEMON') && args[0] === 'daemon') {
profileCheckpoint('cli_daemon_path'); profileCheckpoint('cli_daemon_path')
const { enableConfigs } = await import('../utils/config.js'); const { enableConfigs } = await import('../utils/config.js')
enableConfigs(); enableConfigs()
const { initSinks } = await import('../utils/sinks.js'); const { initSinks } = await import('../utils/sinks.js')
initSinks(); initSinks()
const { daemonMain } = await import('../daemon/main.js'); const { daemonMain } = await import('../daemon/main.js')
await daemonMain(args.slice(1)); await daemonMain(args.slice(1))
return; return
} }
// Fast-path for `claude ps|logs|attach|kill` and `--bg`/`--background`. // 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('--bg') ||
args.includes('--background')) args.includes('--background'))
) { ) {
profileCheckpoint('cli_bg_path'); profileCheckpoint('cli_bg_path')
const { enableConfigs } = await import('../utils/config.js'); const { enableConfigs } = await import('../utils/config.js')
enableConfigs(); enableConfigs()
const bg = await import('../cli/bg.js'); const bg = await import('../cli/bg.js')
switch (args[0]) { switch (args[0]) {
case 'ps': case 'ps':
await bg.psHandler(args.slice(1)); await bg.psHandler(args.slice(1))
break; break
case 'logs': case 'logs':
await bg.logsHandler(args[1]); await bg.logsHandler(args[1])
break; break
case 'attach': case 'attach':
await bg.attachHandler(args[1]); await bg.attachHandler(args[1])
break; break
case 'kill': case 'kill':
await bg.killHandler(args[1]); await bg.killHandler(args[1])
break; break
default: default:
await bg.handleBgFlag(args); await bg.handleBgFlag(args)
} }
return; return
} }
// Fast-path for template job commands. // Fast-path for template job commands.
if (feature('TEMPLATES') && (args[0] === 'new' || args[0] === 'list' || args[0] === 'reply')) { if (
profileCheckpoint('cli_templates_path'); feature('TEMPLATES') &&
const { templatesMain } = await import('../cli/handlers/templateJobs.js'); (args[0] === 'new' || args[0] === 'list' || args[0] === 'reply')
await templatesMain(args); ) {
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 // process.exit (not return) — mountFleetView's Ink TUI can leave event
// loop handles that prevent natural exit. // loop handles that prevent natural exit.
// eslint-disable-next-line custom-rules/no-process-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. // Fast-path for `claude environment-runner`: headless BYOC runner.
// feature() must stay inline for build-time dead code elimination. // feature() must stay inline for build-time dead code elimination.
if (feature('BYOC_ENVIRONMENT_RUNNER') && args[0] === 'environment-runner') { if (feature('BYOC_ENVIRONMENT_RUNNER') && args[0] === 'environment-runner') {
profileCheckpoint('cli_environment_runner_path'); profileCheckpoint('cli_environment_runner_path')
const { environmentRunnerMain } = await import('../environment-runner/main.js'); const { environmentRunnerMain } = await import(
await environmentRunnerMain(args.slice(1)); '../environment-runner/main.js'
return; )
await environmentRunnerMain(args.slice(1))
return
} }
// Fast-path for `claude self-hosted-runner`: headless self-hosted-runner // Fast-path for `claude self-hosted-runner`: headless self-hosted-runner
// targeting the SelfHostedRunnerWorkerService API (register + poll; poll IS // targeting the SelfHostedRunnerWorkerService API (register + poll; poll IS
// heartbeat). feature() must stay inline for build-time dead code elimination. // heartbeat). feature() must stay inline for build-time dead code elimination.
if (feature('SELF_HOSTED_RUNNER') && args[0] === 'self-hosted-runner') { if (feature('SELF_HOSTED_RUNNER') && args[0] === 'self-hosted-runner') {
profileCheckpoint('cli_self_hosted_runner_path'); profileCheckpoint('cli_self_hosted_runner_path')
const { selfHostedRunnerMain } = await import('../self-hosted-runner/main.js'); const { selfHostedRunnerMain } = await import(
await selfHostedRunnerMain(args.slice(1)); '../self-hosted-runner/main.js'
return; )
await selfHostedRunnerMain(args.slice(1))
return
} }
// Fast-path for --worktree --tmux: exec into tmux before loading full CLI // 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 ( if (
hasTmuxFlag && 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'); profileCheckpoint('cli_tmux_worktree_fast_path')
const { enableConfigs } = await import('../utils/config.js'); const { enableConfigs } = await import('../utils/config.js')
enableConfigs(); enableConfigs()
const { isWorktreeModeEnabled } = await import('../utils/worktreeModeEnabled.js'); const { isWorktreeModeEnabled } = await import(
'../utils/worktreeModeEnabled.js'
)
if (isWorktreeModeEnabled()) { if (isWorktreeModeEnabled()) {
const { execIntoTmuxWorktree } = await import('../utils/worktree.js'); const { execIntoTmuxWorktree } = await import('../utils/worktree.js')
const result = await execIntoTmuxWorktree(args); const result = await execIntoTmuxWorktree(args)
if (result.handled) { if (result.handled) {
return; return
} }
// If not handled (e.g., error), fall through to normal CLI // If not handled (e.g., error), fall through to normal CLI
if (result.error) { if (result.error) {
const { exitWithError } = await import('../utils/process.js'); const { exitWithError } = await import('../utils/process.js')
exitWithError(result.error); exitWithError(result.error)
} }
} }
} }
// Redirect common update flag mistakes to the update subcommand // Redirect common update flag mistakes to the update subcommand
if (args.length === 1 && (args[0] === '--update' || args[0] === '--upgrade')) { if (
process.argv = [process.argv[0]!, process.argv[1]!, 'update']; 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 // --bare: set SIMPLE early so gates fire during module eval / commander
// option building (not just inside the action handler). // option building (not just inside the action handler).
if (args.includes('--bare')) { 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 // No special flags detected, load and run the full CLI
const { startCapturingEarlyInput } = await import('../utils/earlyInput.js'); const { startCapturingEarlyInput } = await import('../utils/earlyInput.js')
startCapturingEarlyInput(); startCapturingEarlyInput()
profileCheckpoint('cli_before_main_import'); profileCheckpoint('cli_before_main_import')
const { main: cliMain } = await import('../main.jsx'); const { main: cliMain } = await import('../main.jsx')
profileCheckpoint('cli_after_main_import'); profileCheckpoint('cli_after_main_import')
await cliMain(); await cliMain()
profileCheckpoint('cli_after_main_complete'); profileCheckpoint('cli_after_main_complete')
} }
// eslint-disable-next-line custom-rules/no-top-level-side-effects // 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 { feature } from 'bun:bundle'
import { appendFileSync } from 'fs'; import { appendFileSync } from 'fs'
import React from 'react'; import React from 'react'
import { logEvent } from 'src/services/analytics/index.js'; import { logEvent } from 'src/services/analytics/index.js'
import { gracefulShutdown, gracefulShutdownSync } from 'src/utils/gracefulShutdown.js'; import {
import { type ChannelEntry, getAllowedChannels, setAllowedChannels, setHasDevChannels, setSessionTrustAccepted, setStatsStore } from './bootstrap/state.js'; gracefulShutdown,
import type { Command } from './commands.js'; gracefulShutdownSync,
import { createStatsStore, type StatsStore } from './context/stats.js'; } from 'src/utils/gracefulShutdown.js'
import { getSystemContext } from './context.js'; import {
import { initializeTelemetryAfterTrust } from './entrypoints/init.js'; type ChannelEntry,
import { isSynchronizedOutputSupported } from './ink/terminal.js'; getAllowedChannels,
import type { RenderOptions, Root, TextProps } from './ink.js'; setAllowedChannels,
import { KeybindingSetup } from './keybindings/KeybindingProviderSetup.js'; setHasDevChannels,
import { startDeferredPrefetches } from './main.js'; setSessionTrustAccepted,
import { checkGate_CACHED_OR_BLOCKING, initializeGrowthBook, resetGrowthBook } from './services/analytics/growthbook.js'; setStatsStore,
import { isQualifiedForGrove } from './services/api/grove.js'; } from './bootstrap/state.js'
import { handleMcpjsonServerApprovals } from './services/mcpServerApproval.js'; import type { Command } from './commands.js'
import { AppStateProvider } from './state/AppState.js'; import { createStatsStore, type StatsStore } from './context/stats.js'
import { onChangeAppState } from './state/onChangeAppState.js'; import { getSystemContext } from './context.js'
import { normalizeApiKeyForConfig } from './utils/authPortable.js'; import { initializeTelemetryAfterTrust } from './entrypoints/init.js'
import { getExternalClaudeMdIncludes, getMemoryFiles, shouldShowClaudeMdExternalIncludesWarning } from './utils/claudemd.js'; import { isSynchronizedOutputSupported } from './ink/terminal.js'
import { checkHasTrustDialogAccepted, getCustomApiKeyStatus, getGlobalConfig, saveGlobalConfig } from './utils/config.js'; import type { RenderOptions, Root, TextProps } from './ink.js'
import { updateDeepLinkTerminalPreference } from './utils/deepLink/terminalPreference.js'; import { KeybindingSetup } from './keybindings/KeybindingProviderSetup.js'
import { isEnvTruthy, isRunningOnHomespace } from './utils/envUtils.js'; import { startDeferredPrefetches } from './main.js'
import { type FpsMetrics, FpsTracker } from './utils/fpsTracker.js'; import {
import { updateGithubRepoPathMapping } from './utils/githubRepoPathMapping.js'; checkGate_CACHED_OR_BLOCKING,
import { applyConfigEnvironmentVariables } from './utils/managedEnv.js'; initializeGrowthBook,
import type { PermissionMode } from './utils/permissions/PermissionMode.js'; resetGrowthBook,
import { getBaseRenderOptions } from './utils/renderOptions.js'; } from './services/analytics/growthbook.js'
import { getSettingsWithAllErrors } from './utils/settings/allErrors.js'; import { isQualifiedForGrove } from './services/api/grove.js'
import { hasAutoModeOptIn, hasSkipDangerousModePermissionPrompt } from './utils/settings/settings.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 { export function completeOnboarding(): void {
saveGlobalConfig(current => ({ saveGlobalConfig(current => ({
...current, ...current,
hasCompletedOnboarding: true, 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 => { return new Promise<T>(resolve => {
const done = (result: T): void => void resolve(result); const done = (result: T): void => void resolve(result)
root.render(renderer(done)); 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 * console.error is swallowed by Ink's patchConsole, so we render
* through the React tree instead. * through the React tree instead.
*/ */
export async function exitWithError(root: Root, message: string, beforeExit?: () => Promise<void>): Promise<never> { export async function exitWithError(
return exitWithMessage(root, message, { root: Root,
color: 'error', message: string,
beforeExit 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 * console output is swallowed by Ink's patchConsole, so we render
* through the React tree instead. * through the React tree instead.
*/ */
export async function exitWithMessage(root: Root, message: string, options?: { export async function exitWithMessage(
color?: TextProps['color']; root: Root,
exitCode?: number; message: string,
beforeExit?: () => Promise<void>; options?: {
}): Promise<never> { color?: TextProps['color']
const { exitCode?: number
Text beforeExit?: () => Promise<void>
} = await import('./ink.js'); },
const color = options?.color; ): Promise<never> {
const exitCode = options?.exitCode ?? 1; const { Text } = await import('./ink.js')
root.render(color ? <Text color={color}>{message}</Text> : <Text>{message}</Text>); const color = options?.color
root.unmount(); const exitCode = options?.exitCode ?? 1
await options?.beforeExit?.(); 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 // 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. * Show a setup dialog wrapped in AppStateProvider + KeybindingSetup.
* Reduces boilerplate in showSetupScreens() where every dialog needs these wrappers. * 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?: { export function showSetupDialog<T = void>(
onChangeAppState?: typeof onChangeAppState; root: Root,
}): Promise<T> { renderer: (done: (result: T) => void) => React.ReactNode,
return showDialog<T>(root, done => <AppStateProvider onChangeAppState={options?.onChangeAppState}> options?: { onChangeAppState?: typeof onChangeAppState },
): Promise<T> {
return showDialog<T>(root, done => (
<AppStateProvider onChangeAppState={options?.onChangeAppState}>
<KeybindingSetup>{renderer(done)}</KeybindingSetup> <KeybindingSetup>{renderer(done)}</KeybindingSetup>
</AppStateProvider>); </AppStateProvider>
))
} }
/** /**
* Render the main UI into the root and wait for it to exit. * 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. * Handles the common epilogue: start deferred prefetches, wait for exit, graceful shutdown.
*/ */
export async function renderAndRun(root: Root, element: React.ReactNode): Promise<void> { export async function renderAndRun(
root.render(element); root: Root,
startDeferredPrefetches(); element: React.ReactNode,
await root.waitUntilExit(); ): Promise<void> {
await gracefulShutdown(0); 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; const config = getGlobalConfig()
if (!config.theme || !config.hasCompletedOnboarding // always show onboarding at least once let onboardingShown = false
if (
!config.theme ||
!config.hasCompletedOnboarding // always show onboarding at least once
) { ) {
onboardingShown = true; onboardingShown = true
const { const { Onboarding } = await import('./components/Onboarding.js')
Onboarding await showSetupDialog(
} = await import('./components/Onboarding.js'); root,
await showSetupDialog(root, done => <Onboarding onDone={() => { done => (
completeOnboarding(); <Onboarding
void done(); onDone={() => {
}} />, { completeOnboarding()
onChangeAppState void done()
}); }}
/>
),
{ onChangeAppState },
)
} }
// Always show the trust dialog in interactive sessions, regardless of permission mode. // 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 // If it returns true, the TrustDialog would auto-resolve regardless of
// security features, so we can skip the dynamic import and render cycle. // security features, so we can skip the dynamic import and render cycle.
if (!checkHasTrustDialogAccepted()) { if (!checkHasTrustDialogAccepted()) {
const { const { TrustDialog } = await import(
TrustDialog './components/TrustDialog/TrustDialog.js'
} = await import('./components/TrustDialog/TrustDialog.js'); )
await showSetupDialog(root, done => <TrustDialog commands={commands} onDone={done} />); await showSetupDialog(root, done => (
<TrustDialog commands={commands} onDone={done} />
))
} }
// Signal that trust has been verified for this session. // Signal that trust has been verified for this session.
// GrowthBook checks this to decide whether to include auth headers. // GrowthBook checks this to decide whether to include auth headers.
setSessionTrustAccepted(true); setSessionTrustAccepted(true)
// Reset and reinitialize GrowthBook after trust is established. // Reset and reinitialize GrowthBook after trust is established.
// Defense for login/logout: clears any prior client so the next init // Defense for login/logout: clears any prior client so the next init
// picks up fresh auth headers. // picks up fresh auth headers.
resetGrowthBook(); resetGrowthBook()
void initializeGrowthBook(); void initializeGrowthBook()
// Now that trust is established, prefetch system context if it wasn't already // 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 // If settings are valid, check for any mcp.json servers that need approval
const { const { errors: allErrors } = getSettingsWithAllErrors()
errors: allErrors
} = getSettingsWithAllErrors();
if (allErrors.length === 0) { if (allErrors.length === 0) {
await handleMcpjsonServerApprovals(root); await handleMcpjsonServerApprovals(root)
} }
// Check for claude.md includes that need approval // Check for claude.md includes that need approval
if (await shouldShowClaudeMdExternalIncludesWarning()) { if (await shouldShowClaudeMdExternalIncludesWarning()) {
const externalIncludes = getExternalClaudeMdIncludes(await getMemoryFiles(true)); const externalIncludes = getExternalClaudeMdIncludes(
const { await getMemoryFiles(true),
ClaudeMdExternalIncludesDialog )
} = await import('./components/ClaudeMdExternalIncludesDialog.js'); const { ClaudeMdExternalIncludesDialog } = await import(
await showSetupDialog(root, done => <ClaudeMdExternalIncludesDialog onDone={done} isStandaloneDialog externalIncludes={externalIncludes} />); './components/ClaudeMdExternalIncludesDialog.js'
)
await showSetupDialog(root, done => (
<ClaudeMdExternalIncludesDialog
onDone={done}
isStandaloneDialog
externalIncludes={externalIncludes}
/>
))
} }
} }
// Track current repo path for teleport directory switching (fire-and-forget) // Track current repo path for teleport directory switching (fire-and-forget)
// This must happen AFTER trust to prevent untrusted directories from poisoning the mapping // This must happen AFTER trust to prevent untrusted directories from poisoning the mapping
void updateGithubRepoPathMapping(); void updateGithubRepoPathMapping()
if (feature('LODESTONE')) { if (feature('LODESTONE')) {
updateDeepLinkTerminalPreference(); updateDeepLinkTerminalPreference()
} }
// Apply full environment variables after trust dialog is accepted OR in bypass mode // 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 bypass mode (CI/CD, automation), we trust the environment so apply all variables
// In normal mode, this happens after the trust dialog is accepted // In normal mode, this happens after the trust dialog is accepted
// This includes potentially dangerous environment variables from untrusted sources // This includes potentially dangerous environment variables from untrusted sources
applyConfigEnvironmentVariables(); applyConfigEnvironmentVariables()
// Initialize telemetry after env vars are applied so OTEL endpoint env vars and // Initialize telemetry after env vars are applied so OTEL endpoint env vars and
// otelHeadersHelper (which requires trust to execute) are available. // otelHeadersHelper (which requires trust to execute) are available.
// Defer to next tick so the OTel dynamic import resolves after first render // Defer to next tick so the OTel dynamic import resolves after first render
// instead of during the pre-render microtask queue. // instead of during the pre-render microtask queue.
setImmediate(() => initializeTelemetryAfterTrust()); setImmediate(() => initializeTelemetryAfterTrust())
if (await isQualifiedForGrove()) { if (await isQualifiedForGrove()) {
const { const { GroveDialog } = await import('src/components/grove/Grove.js')
GroveDialog const decision = await showSetupDialog<string>(root, done => (
} = await import('src/components/grove/Grove.js'); <GroveDialog
const decision = await showSetupDialog<string>(root, done => <GroveDialog showIfAlreadyViewed={false} location={onboardingShown ? 'onboarding' : 'policy_update_modal'} onDone={done} />); showIfAlreadyViewed={false}
location={onboardingShown ? 'onboarding' : 'policy_update_modal'}
onDone={done}
/>
))
if (decision === 'escape') { if (decision === 'escape') {
logEvent('tengu_grove_policy_exited', {}); logEvent('tengu_grove_policy_exited', {})
gracefulShutdownSync(0); gracefulShutdownSync(0)
return false; 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 // On homespace, ANTHROPIC_API_KEY is preserved in process.env for child
// processes but ignored by Claude Code itself (see auth.ts). // processes but ignored by Claude Code itself (see auth.ts).
if (process.env.ANTHROPIC_API_KEY && !isRunningOnHomespace()) { if (process.env.ANTHROPIC_API_KEY && !isRunningOnHomespace()) {
const customApiKeyTruncated = normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY); const customApiKeyTruncated = normalizeApiKeyForConfig(
const keyStatus = getCustomApiKeyStatus(customApiKeyTruncated); process.env.ANTHROPIC_API_KEY,
)
const keyStatus = getCustomApiKeyStatus(customApiKeyTruncated)
if (keyStatus === 'new') { if (keyStatus === 'new') {
const { const { ApproveApiKey } = await import('./components/ApproveApiKey.js')
ApproveApiKey await showSetupDialog<boolean>(
} = await import('./components/ApproveApiKey.js'); root,
await showSetupDialog<boolean>(root, done => <ApproveApiKey customApiKeyTruncated={customApiKeyTruncated} onDone={done} />, { done => (
onChangeAppState <ApproveApiKey
}); customApiKeyTruncated={customApiKeyTruncated}
onDone={done}
/>
),
{ onChangeAppState },
)
} }
} }
if ((permissionMode === 'bypassPermissions' || allowDangerouslySkipPermissions) && !hasSkipDangerousModePermissionPrompt()) {
const { if (
BypassPermissionsModeDialog (permissionMode === 'bypassPermissions' ||
} = await import('./components/BypassPermissionsModeDialog.js'); allowDangerouslySkipPermissions) &&
await showSetupDialog(root, done => <BypassPermissionsModeDialog onAccept={done} />); !hasSkipDangerousModePermissionPrompt()
) {
const { BypassPermissionsModeDialog } = await import(
'./components/BypassPermissionsModeDialog.js'
)
await showSetupDialog(root, done => (
<BypassPermissionsModeDialog onAccept={done} />
))
} }
if (feature('TRANSCRIPT_CLASSIFIER')) { if (feature('TRANSCRIPT_CLASSIFIER')) {
// Only show the opt-in dialog if auto mode actually resolved — if the // Only show the opt-in dialog if auto mode actually resolved — if the
// gate denied it (org not allowlisted, settings disabled), showing // gate denied it (org not allowlisted, settings disabled), showing
// consent for an unavailable feature is pointless. The // consent for an unavailable feature is pointless. The
// verifyAutoModeGateAccess notification will explain why instead. // verifyAutoModeGateAccess notification will explain why instead.
if (permissionMode === 'auto' && !hasAutoModeOptIn()) { if (permissionMode === 'auto' && !hasAutoModeOptIn()) {
const { const { AutoModeOptInDialog } = await import(
AutoModeOptInDialog './components/AutoModeOptInDialog.js'
} = await import('./components/AutoModeOptInDialog.js'); )
await showSetupDialog(root, done => <AutoModeOptInDialog onAccept={done} onDecline={() => gracefulShutdownSync(1)} declineExits />); 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 // initializeGrowthBook promise fired earlier). Also warms the
// isChannelsEnabled() check in the dev-channels dialog below. // isChannelsEnabled() check in the dev-channels dialog below.
if (getAllowedChannels().length > 0 || (devChannels?.length ?? 0) > 0) { 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) { if (devChannels && devChannels.length > 0) {
const [{ const [{ isChannelsEnabled }, { getClaudeAIOAuthTokens }] =
isChannelsEnabled await Promise.all([
}, { import('./services/mcp/channelAllowlist.js'),
getClaudeAIOAuthTokens import('./utils/auth.js'),
}] = await Promise.all([import('./services/mcp/channelAllowlist.js'), import('./utils/auth.js')]); ])
// Skip the dialog when channels are blocked (tengu_harbor off or no // Skip the dialog when channels are blocked (tengu_harbor off or no
// OAuth) — accepting then immediately seeing "not available" in // OAuth) — accepting then immediately seeing "not available" in
// ChannelsNotice is worse than no dialog. Append entries anyway so // ChannelsNotice is worse than no dialog. Append entries anyway so
@@ -264,67 +359,80 @@ export async function showSetupScreens(root: Root, permissionMode: PermissionMod
// (hasNonDev check); the allowlist bypass it also grants is moot // (hasNonDev check); the allowlist bypass it also grants is moot
// since the gate blocks upstream. // since the gate blocks upstream.
if (!isChannelsEnabled() || !getClaudeAIOAuthTokens()?.accessToken) { if (!isChannelsEnabled() || !getClaudeAIOAuthTokens()?.accessToken) {
setAllowedChannels([...getAllowedChannels(), ...devChannels.map(c => ({ setAllowedChannels([
...c, ...getAllowedChannels(),
dev: true ...devChannels.map(c => ({ ...c, dev: true })),
}))]); ])
setHasDevChannels(true); setHasDevChannels(true)
} else { } else {
const { const { DevChannelsDialog } = await import(
DevChannelsDialog './components/DevChannelsDialog.js'
} = await import('./components/DevChannelsDialog.js'); )
await showSetupDialog(root, done => <DevChannelsDialog channels={devChannels} onAccept={() => { await showSetupDialog(root, done => (
<DevChannelsDialog
channels={devChannels}
onAccept={() => {
// Mark dev entries per-entry so the allowlist bypass doesn't leak // Mark dev entries per-entry so the allowlist bypass doesn't leak
// to --channels entries when both flags are passed. // to --channels entries when both flags are passed.
setAllowedChannels([...getAllowedChannels(), ...devChannels.map(c => ({ setAllowedChannels([
...c, ...getAllowedChannels(),
dev: true ...devChannels.map(c => ({ ...c, dev: true })),
}))]); ])
setHasDevChannels(true); setHasDevChannels(true)
void done(); void done()
}} />); }}
/>
))
} }
} }
} }
// Show Chrome onboarding for first-time Claude in Chrome users // Show Chrome onboarding for first-time Claude in Chrome users
if (claudeInChrome && !getGlobalConfig().hasCompletedClaudeInChromeOnboarding) { if (
const { claudeInChrome &&
ClaudeInChromeOnboarding !getGlobalConfig().hasCompletedClaudeInChromeOnboarding
} = await import('./components/ClaudeInChromeOnboarding.js'); ) {
await showSetupDialog(root, done => <ClaudeInChromeOnboarding onDone={done} />); const { ClaudeInChromeOnboarding } = await import(
'./components/ClaudeInChromeOnboarding.js'
)
await showSetupDialog(root, done => (
<ClaudeInChromeOnboarding onDone={done} />
))
} }
return onboardingShown;
return onboardingShown
} }
export function getRenderContext(exitOnCtrlC: boolean): { export function getRenderContext(exitOnCtrlC: boolean): {
renderOptions: RenderOptions; renderOptions: RenderOptions
getFpsMetrics: () => FpsMetrics | undefined; getFpsMetrics: () => FpsMetrics | undefined
stats: StatsStore; stats: StatsStore
} { } {
let lastFlickerTime = 0; let lastFlickerTime = 0
const baseOptions = getBaseRenderOptions(exitOnCtrlC); const baseOptions = getBaseRenderOptions(exitOnCtrlC)
// Log analytics event when stdin override is active // Log analytics event when stdin override is active
if (baseOptions.stdin) { if (baseOptions.stdin) {
logEvent('tengu_stdin_interactive', {}); logEvent('tengu_stdin_interactive', {})
} }
const fpsTracker = new FpsTracker();
const stats = createStatsStore(); const fpsTracker = new FpsTracker()
setStatsStore(stats); const stats = createStatsStore()
setStatsStore(stats)
// Bench mode: when set, append per-frame phase timings as JSONL for // Bench mode: when set, append per-frame phase timings as JSONL for
// offline analysis by bench/repl-scroll.ts. Captures the full TUI // offline analysis by bench/repl-scroll.ts. Captures the full TUI
// render pipeline (yoga → screen buffer → diff → optimize → stdout) // render pipeline (yoga → screen buffer → diff → optimize → stdout)
// so perf work on any phase can be validated against real user flows. // 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 { return {
getFpsMetrics: () => fpsTracker.getMetrics(), getFpsMetrics: () => fpsTracker.getMetrics(),
stats, stats,
renderOptions: { renderOptions: {
...baseOptions, ...baseOptions,
onFrame: event => { onFrame: event => {
fpsTracker.record(event.durationMs); fpsTracker.record(event.durationMs)
stats.observe('frame_duration_ms', event.durationMs); stats.observe('frame_duration_ms', event.durationMs)
if (frameTimingLogPath && event.phases) { if (frameTimingLogPath && event.phases) {
// Bench-only env-var-gated path: sync write so no frames dropped // Bench-only env-var-gated path: sync write so no frames dropped
// on abrupt exit. ~100 bytes at ≤60fps is negligible. rss/cpu are // on abrupt exit. ~100 bytes at ≤60fps is negligible. rss/cpu are
@@ -335,31 +443,31 @@ export function getRenderContext(exitOnCtrlC: boolean): {
total: event.durationMs, total: event.durationMs,
...event.phases, ...event.phases,
rss: process.memoryUsage.rss(), rss: process.memoryUsage.rss(),
cpu: process.cpuUsage() cpu: process.cpuUsage(),
}) + '\n'; }) + '\n'
// eslint-disable-next-line custom-rules/no-sync-fs -- bench-only, sync so no frames dropped on exit // 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 — // Skip flicker reporting for terminals with synchronized output —
// DEC 2026 buffers between BSU/ESU so clear+redraw is atomic. // DEC 2026 buffers between BSU/ESU so clear+redraw is atomic.
if (isSynchronizedOutputSupported()) { if (isSynchronizedOutputSupported()) {
return; return
} }
for (const flicker of event.flickers) { for (const flicker of event.flickers) {
if (flicker.reason === 'resize') { if (flicker.reason === 'resize') {
continue; continue
} }
const now = Date.now(); const now = Date.now()
if (now - lastFlickerTime < 1000) { if (now - lastFlickerTime < 1000) {
logEvent('tengu_flicker', { logEvent('tengu_flicker', {
desiredHeight: flicker.desiredHeight, desiredHeight: flicker.desiredHeight,
actualHeight: flicker.availableHeight, actualHeight: flicker.availableHeight,
reason: flicker.reason reason: flicker.reason,
} as unknown as Record<string, boolean | number | undefined>); } 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). // would resolve to scripts/external-stubs/src/types/ (doesn't exist).
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type M = any; type M = any
export function useMoreRight(_args: { export function useMoreRight(_args: {
enabled: boolean; enabled: boolean
setMessages: (action: M[] | ((prev: M[]) => M[])) => void; setMessages: (action: M[] | ((prev: M[]) => M[])) => void
inputValue: string; inputValue: string
setInputValue: (s: string) => void; setInputValue: (s: string) => void
setToolJSX: (args: M) => void; setToolJSX: (args: M) => void
}): { }): {
onBeforeQuery: (input: string, all: M[], n: number) => Promise<boolean>; onBeforeQuery: (input: string, all: M[], n: number) => Promise<boolean>
onTurnComplete: (all: M[], aborted: boolean) => Promise<void>; onTurnComplete: (all: M[], aborted: boolean) => Promise<void>
render: () => null; render: () => null
} { } {
return { return {
onBeforeQuery: async () => true, onBeforeQuery: async () => true,
onTurnComplete: async () => {}, onTurnComplete: async () => {},
render: () => null render: () => null,
}; }
} }

View File

@@ -1,22 +1,28 @@
import React from 'react'; import React from 'react'
import type { StatsStore } from './context/stats.js'; import type { StatsStore } from './context/stats.js'
import type { Root } from './ink.js'; import type { Root } from './ink.js'
import type { Props as REPLProps } from './screens/REPL.js'; import type { Props as REPLProps } from './screens/REPL.js'
import type { AppState } from './state/AppStateStore.js'; import type { AppState } from './state/AppStateStore.js'
import type { FpsMetrics } from './utils/fpsTracker.js'; import type { FpsMetrics } from './utils/fpsTracker.js'
type AppWrapperProps = { type AppWrapperProps = {
getFpsMetrics: () => FpsMetrics | undefined; getFpsMetrics: () => FpsMetrics | undefined
stats?: StatsStore; stats?: StatsStore
initialState: AppState; initialState: AppState
}; }
export async function launchRepl(root: Root, appProps: AppWrapperProps, replProps: REPLProps, renderAndRun: (root: Root, element: React.ReactNode) => Promise<void>): Promise<void> {
const { export async function launchRepl(
App root: Root,
} = await import('./components/App.js'); appProps: AppWrapperProps,
const { replProps: REPLProps,
REPL renderAndRun: (root: Root, element: React.ReactNode) => Promise<void>,
} = await import('./screens/REPL.js'); ): Promise<void> {
await renderAndRun(root, <App {...appProps}> const { App } = await import('./components/App.js')
<REPL {...replProps} /> const { REPL } = await import('./screens/REPL.js')
</App>); 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 * as React from 'react'; import { useEffect, useRef } from 'react'
import { useEffect, useRef } from 'react'; import { KeyboardShortcutHint } from '../components/design-system/KeyboardShortcutHint.js'
import { KeyboardShortcutHint } from '../components/design-system/KeyboardShortcutHint.js'; import { Box, Text } from '../ink.js'
import { Box, Text } from '../ink.js'; import { useKeybinding } from '../keybindings/useKeybinding.js'
import { useKeybinding } from '../keybindings/useKeybinding.js';
type Props = { type Props = {
onRun: () => void; onRun: () => void
onCancel: () => void; onCancel: () => void
reason: string; reason: string
}; }
/** /**
* Component that shows a notification about running /issue command * Component that shows a notification about running /issue command
* with the ability to cancel via ESC key * with the ability to cancel via ESC key
*/ */
export function AutoRunIssueNotification(t0) { export function AutoRunIssueNotification({
const $ = _c(8);
const {
onRun, onRun,
onCancel, onCancel,
reason reason,
} = t0; }: Props): React.ReactNode {
const hasRunRef = useRef(false); const hasRunRef = useRef(false)
let t1;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) { // Handle ESC key to cancel
t1 = { useKeybinding('confirm:no', onCancel, { context: 'Confirmation' })
context: "Confirmation"
}; // Run /issue immediately on mount
$[0] = t1; useEffect(() => {
} else {
t1 = $[0];
}
useKeybinding("confirm:no", onCancel, t1);
let t2;
let t3;
if ($[1] !== onRun) {
t2 = () => {
if (!hasRunRef.current) { if (!hasRunRef.current) {
hasRunRef.current = true; hasRunRef.current = true
onRun(); onRun()
} }
}; }, [onRun])
t3 = [onRun];
$[1] = onRun; return (
$[2] = t2; <Box flexDirection="column" marginTop={1}>
$[3] = t3; <Box>
} else { <Text bold>Running feedback capture...</Text>
t2 = $[2]; </Box>
t3 = $[3]; <Box>
<Text dimColor>
Press <KeyboardShortcutHint shortcut="Esc" action="cancel" /> anytime
</Text>
</Box>
<Box>
<Text dimColor>Reason: {reason}</Text>
</Box>
</Box>
)
} }
useEffect(t2, t3);
let t4; export type AutoRunIssueReason = 'feedback_survey_bad' | 'feedback_survey_good'
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 type AutoRunIssueReason = 'feedback_survey_bad' | 'feedback_survey_good';
/** /**
* Determines if /issue should auto-run for Ant users * Determines if /issue should auto-run for Ant users
*/ */
export function shouldAutoRunIssue(reason: AutoRunIssueReason): boolean { export function shouldAutoRunIssue(reason: AutoRunIssueReason): boolean {
// Only for Ant users // Only for Ant users
if ((process.env.USER_TYPE) !== 'ant') { if (process.env.USER_TYPE !== 'ant') {
return false; return false
} }
switch (reason) { switch (reason) {
case 'feedback_survey_bad': case 'feedback_survey_bad':
return false; return false
case 'feedback_survey_good': case 'feedback_survey_good':
return false; return false
default: default:
return false; return false
} }
} }
@@ -100,10 +76,10 @@ export function shouldAutoRunIssue(reason: AutoRunIssueReason): boolean {
*/ */
export function getAutoRunCommand(reason: AutoRunIssueReason): string { export function getAutoRunCommand(reason: AutoRunIssueReason): string {
// Only ant builds have the /good-claude command // Only ant builds have the /good-claude command
if ((process.env.USER_TYPE) === 'ant' && reason === 'feedback_survey_good') { if (process.env.USER_TYPE === 'ant' && reason === 'feedback_survey_good') {
return '/good-claude'; return '/good-claude'
} }
return '/issue'; return '/issue'
} }
/** /**
@@ -112,10 +88,10 @@ export function getAutoRunCommand(reason: AutoRunIssueReason): string {
export function getAutoRunIssueReasonText(reason: AutoRunIssueReason): string { export function getAutoRunIssueReasonText(reason: AutoRunIssueReason): string {
switch (reason) { switch (reason) {
case 'feedback_survey_bad': case 'feedback_survey_bad':
return 'You responded "Bad" to the feedback survey'; return 'You responded "Bad" to the feedback survey'
case 'feedback_survey_good': case 'feedback_survey_good':
return 'You responded "Good" to the feedback survey'; return 'You responded "Good" to the feedback survey'
default: default:
return 'Unknown reason'; return 'Unknown reason'
} }
} }

View File

@@ -1,104 +1,146 @@
import * as React from 'react'; import * as React from 'react'
import { MessageResponse } from '../../components/MessageResponse.js'; import { MessageResponse } from '../../components/MessageResponse.js'
import { supportsHyperlinks } from '../../ink/supports-hyperlinks.js'; import { supportsHyperlinks } from '../../ink/supports-hyperlinks.js'
import { Link, Text } from '../../ink.js'; import { Link, Text } from '../../ink.js'
import { renderToolResultMessage as renderDefaultMCPToolResultMessage } from '../../tools/MCPTool/UI.js'; import { renderToolResultMessage as renderDefaultMCPToolResultMessage } from '../../tools/MCPTool/UI.js'
import type { MCPToolResult } from '../../utils/mcpValidation.js'; import type { MCPToolResult } from '../../utils/mcpValidation.js'
import { truncateToWidth } from '../format.js'; import { truncateToWidth } from '../format.js'
import { trackClaudeInChromeTabId } from './common.js'; import { trackClaudeInChromeTabId } from './common.js'
export type { Tool } from '@modelcontextprotocol/sdk/types.js';
export type { Tool } from '@modelcontextprotocol/sdk/types.js'
/** /**
* All tool names from BROWSER_TOOLS in @ant/claude-for-chrome-mcp. * All tool names from BROWSER_TOOLS in @ant/claude-for-chrome-mcp.
* Keep in sync with the package's BROWSER_TOOLS array. * 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'; export type ChromeToolName =
const CHROME_EXTENSION_FOCUS_TAB_URL_BASE = 'https://clau.de/chrome/tab/'; | 'javascript_tool'
function renderChromeToolUseMessage(input: Record<string, unknown>, toolName: ChromeToolName, verbose: boolean): React.ReactNode { | 'read_page'
const tabId = input.tabId; | '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') { if (typeof tabId === 'number') {
trackClaudeInChromeTabId(tabId); trackClaudeInChromeTabId(tabId)
} }
// Build secondary info based on tool type and input // Build secondary info based on tool type and input
const secondaryInfo: string[] = []; const secondaryInfo: string[] = []
switch (toolName) { switch (toolName) {
case 'navigate': case 'navigate':
if (typeof input.url === 'string') { if (typeof input.url === 'string') {
try { try {
const url = new URL(input.url); const url = new URL(input.url)
secondaryInfo.push(url.hostname); secondaryInfo.push(url.hostname)
} catch { } catch {
secondaryInfo.push(truncateToWidth(input.url, 30)); secondaryInfo.push(truncateToWidth(input.url, 30))
} }
} }
break; break
case 'find': case 'find':
if (typeof input.query === 'string') { if (typeof input.query === 'string') {
secondaryInfo.push(`pattern: ${truncateToWidth(input.query, 30)}`); secondaryInfo.push(`pattern: ${truncateToWidth(input.query, 30)}`)
} }
break; break
case 'computer': case 'computer':
if (typeof input.action === 'string') { if (typeof input.action === 'string') {
const action = input.action; const action = input.action
if (action === 'left_click' || action === 'right_click' || action === 'double_click' || action === 'middle_click') { if (
action === 'left_click' ||
action === 'right_click' ||
action === 'double_click' ||
action === 'middle_click'
) {
if (typeof input.ref === 'string') { if (typeof input.ref === 'string') {
secondaryInfo.push(`${action} on ${input.ref}`); secondaryInfo.push(`${action} on ${input.ref}`)
} else if (Array.isArray(input.coordinate)) { } else if (Array.isArray(input.coordinate)) {
secondaryInfo.push(`${action} at (${input.coordinate.join(', ')})`); secondaryInfo.push(`${action} at (${input.coordinate.join(', ')})`)
} else { } else {
secondaryInfo.push(action); secondaryInfo.push(action)
} }
} else if (action === 'type' && typeof input.text === 'string') { } 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') { } else if (action === 'key' && typeof input.text === 'string') {
secondaryInfo.push(`key ${input.text}`); secondaryInfo.push(`key ${input.text}`)
} else if (action === 'scroll' && typeof input.scroll_direction === 'string') { } else if (
secondaryInfo.push(`scroll ${input.scroll_direction}`); action === 'scroll' &&
typeof input.scroll_direction === 'string'
) {
secondaryInfo.push(`scroll ${input.scroll_direction}`)
} else if (action === 'wait' && typeof input.duration === 'number') { } 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') { } else if (action === 'left_click_drag') {
secondaryInfo.push('drag'); secondaryInfo.push('drag')
} else { } else {
secondaryInfo.push(action); secondaryInfo.push(action)
} }
} }
break; break
case 'gif_creator': case 'gif_creator':
if (typeof input.action === 'string') { if (typeof input.action === 'string') {
secondaryInfo.push(`${input.action}`); secondaryInfo.push(`${input.action}`)
} }
break; break
case 'resize_window': case 'resize_window':
if (typeof input.width === 'number' && typeof input.height === 'number') { 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': case 'read_console_messages':
if (typeof input.pattern === 'string') { if (typeof input.pattern === 'string') {
secondaryInfo.push(`pattern: ${truncateToWidth(input.pattern, 20)}`); secondaryInfo.push(`pattern: ${truncateToWidth(input.pattern, 20)}`)
} }
if (input.onlyErrors === true) { if (input.onlyErrors === true) {
secondaryInfo.push('errors only'); secondaryInfo.push('errors only')
} }
break; break
case 'read_network_requests': case 'read_network_requests':
if (typeof input.urlPattern === 'string') { if (typeof input.urlPattern === 'string') {
secondaryInfo.push(`pattern: ${truncateToWidth(input.urlPattern, 20)}`); secondaryInfo.push(`pattern: ${truncateToWidth(input.urlPattern, 20)}`)
} }
break; break
case 'shortcuts_execute': case 'shortcuts_execute':
if (typeof input.shortcutId === 'string') { if (typeof input.shortcutId === 'string') {
secondaryInfo.push(`shortcut_id: ${input.shortcutId}`); secondaryInfo.push(`shortcut_id: ${input.shortcutId}`)
} }
break; break
case 'javascript_tool': case 'javascript_tool':
// In verbose mode, show the full code // In verbose mode, show the full code
if (verbose && typeof input.text === 'string') { if (verbose && typeof input.text === 'string') {
return input.text; return input.text
} }
// In non-verbose mode, return empty string to preserve View Tab layout // In non-verbose mode, return empty string to preserve View Tab layout
return ''; return ''
case 'tabs_create_mcp': case 'tabs_create_mcp':
case 'tabs_context_mcp': case 'tabs_context_mcp':
case 'form_input': case 'form_input':
@@ -109,9 +151,10 @@ function renderChromeToolUseMessage(input: Record<string, unknown>, toolName: Ch
case 'update_plan': case 'update_plan':
// These tools don't have meaningful secondary info to show inline. // These tools don't have meaningful secondary info to show inline.
// Return empty string (not null) to ensure tool header still renders. // 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 { function renderChromeViewTabLink(input: unknown): React.ReactNode {
if (!supportsHyperlinks()) { if (!supportsHyperlinks()) {
return null; return null
} }
if (typeof input !== 'object' || input === null || !('tabId' in input)) { 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)) { if (isNaN(tabId)) {
return null; return null
} }
const linkUrl = `${CHROME_EXTENSION_FOCUS_TAB_URL_BASE}${tabId}`; const linkUrl = `${CHROME_EXTENSION_FOCUS_TAB_URL_BASE}${tabId}`
return <Text> return (
<Text>
{' '} {' '}
<Link url={linkUrl}> <Link url={linkUrl}>
<Text color="subtle">[View Tab]</Text> <Text color="subtle">[View Tab]</Text>
</Link> </Link>
</Text>; </Text>
)
} }
/** /**
@@ -146,72 +196,79 @@ function renderChromeViewTabLink(input: unknown): React.ReactNode {
* Shows a brief summary for successful results. Errors are handled by * Shows a brief summary for successful results. Errors are handled by
* the default renderToolUseErrorMessage when is_error is set. * 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) { if (verbose) {
return renderDefaultMCPToolResultMessage(output, [], { return renderDefaultMCPToolResultMessage(output, [], { verbose })
verbose
});
} }
let summary: string | null = null;
let summary: string | null = null
switch (toolName) { switch (toolName) {
case 'navigate': case 'navigate':
summary = 'Navigation completed'; summary = 'Navigation completed'
break; break
case 'tabs_create_mcp': case 'tabs_create_mcp':
summary = 'Tab created'; summary = 'Tab created'
break; break
case 'tabs_context_mcp': case 'tabs_context_mcp':
summary = 'Tabs read'; summary = 'Tabs read'
break; break
case 'form_input': case 'form_input':
summary = 'Input completed'; summary = 'Input completed'
break; break
case 'computer': case 'computer':
summary = 'Action completed'; summary = 'Action completed'
break; break
case 'resize_window': case 'resize_window':
summary = 'Window resized'; summary = 'Window resized'
break; break
case 'find': case 'find':
summary = 'Search completed'; summary = 'Search completed'
break; break
case 'gif_creator': case 'gif_creator':
summary = 'GIF action completed'; summary = 'GIF action completed'
break; break
case 'read_console_messages': case 'read_console_messages':
summary = 'Console messages retrieved'; summary = 'Console messages retrieved'
break; break
case 'read_network_requests': case 'read_network_requests':
summary = 'Network requests retrieved'; summary = 'Network requests retrieved'
break; break
case 'shortcuts_list': case 'shortcuts_list':
summary = 'Shortcuts retrieved'; summary = 'Shortcuts retrieved'
break; break
case 'shortcuts_execute': case 'shortcuts_execute':
summary = 'Shortcut executed'; summary = 'Shortcut executed'
break; break
case 'javascript_tool': case 'javascript_tool':
summary = 'Script executed'; summary = 'Script executed'
break; break
case 'read_page': case 'read_page':
summary = 'Page read'; summary = 'Page read'
break; break
case 'upload_image': case 'upload_image':
summary = 'Image uploaded'; summary = 'Image uploaded'
break; break
case 'get_page_text': case 'get_page_text':
summary = 'Page text retrieved'; summary = 'Page text retrieved'
break; break
case 'update_plan': case 'update_plan':
summary = 'Plan updated'; summary = 'Plan updated'
break; break
} }
if (summary) { if (summary) {
return <MessageResponse height={1}> return (
<MessageResponse height={1}>
<Text dimColor>{summary}</Text> <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. * rendering for chrome tools in a single spread operation.
*/ */
export function getClaudeInChromeMCPToolOverrides(toolName: string): { export function getClaudeInChromeMCPToolOverrides(toolName: string): {
userFacingName: (input?: Record<string, unknown>) => string; userFacingName: (input?: Record<string, unknown>) => string
renderToolUseMessage: (input: Record<string, unknown>, options: { renderToolUseMessage: (
verbose: boolean; input: Record<string, unknown>,
}) => React.ReactNode; options: { verbose: boolean },
renderToolUseTag: (input: Partial<Record<string, unknown>>) => React.ReactNode; ) => React.ReactNode
renderToolResultMessage: (output: string | MCPToolResult, progressMessagesForMessage: unknown[], options: { renderToolUseTag: (input: Partial<Record<string, unknown>>) => React.ReactNode
verbose: boolean; renderToolResultMessage: (
}) => React.ReactNode; output: string | MCPToolResult,
progressMessagesForMessage: unknown[],
options: { verbose: boolean },
) => React.ReactNode
} { } {
return { return {
userFacingName(_input?: Record<string, unknown>) { userFacingName(_input?: Record<string, unknown>) {
// Trim the _mcp postfix that show up in some of the tool names // Trim the _mcp postfix that show up in some of the tool names
const displayName = toolName.replace(/_mcp$/, ''); const displayName = toolName.replace(/_mcp$/, '')
return `Claude in Chrome[${displayName}]`; return `Claude in Chrome[${displayName}]`
}, },
renderToolUseMessage(input: Record<string, unknown>, { renderToolUseMessage(
verbose input: Record<string, unknown>,
}: { { verbose }: { verbose: boolean },
verbose: boolean; ): React.ReactNode {
}): React.ReactNode { return renderChromeToolUseMessage(
return renderChromeToolUseMessage(input, toolName as ChromeToolName, verbose); input,
toolName as ChromeToolName,
verbose,
)
}, },
renderToolUseTag(input: Partial<Record<string, unknown>>): React.ReactNode { renderToolUseTag(input: Partial<Record<string, unknown>>): React.ReactNode {
return renderChromeViewTabLink(input); return renderChromeViewTabLink(input)
}, },
renderToolResultMessage(output: string | MCPToolResult, _progressMessagesForMessage: unknown[], { renderToolResultMessage(
verbose output: string | MCPToolResult,
}: { _progressMessagesForMessage: unknown[],
verbose: boolean; { verbose }: { verbose: boolean },
}): React.ReactNode { ): React.ReactNode {
if (!isMCPToolResult(output)) { 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 * as React from 'react'
import { MessageResponse } from '../../components/MessageResponse.js'; import { MessageResponse } from '../../components/MessageResponse.js'
import { Text } from '../../ink.js'; import { Text } from '../../ink.js'
import { truncateToWidth } from '../format.js'; import { truncateToWidth } from '../format.js'
import type { MCPToolResult } from '../mcpValidation.js'; import type { MCPToolResult } from '../mcpValidation.js'
type CuToolInput = Record<string, unknown> & { type CuToolInput = Record<string, unknown> & {
coordinate?: [number, number]; coordinate?: [number, number]
start_coordinate?: [number, number]; start_coordinate?: [number, number]
text?: string; text?: string
apps?: Array<{ apps?: Array<{ displayName?: string }>
displayName?: string; region?: [number, number, number, number]
}>; direction?: string
region?: [number, number, number, number]; amount?: number
direction?: string; duration?: number
amount?: number;
duration?: number;
};
function fmtCoord(c: [number, number] | undefined): string {
return c ? `(${c[0]}, ${c[1]})` : '';
} }
function fmtCoord(c: [number, number] | undefined): string {
return c ? `(${c[0]}, ${c[1]})` : ''
}
const RESULT_SUMMARY: Readonly<Partial<Record<string, string>>> = { const RESULT_SUMMARY: Readonly<Partial<Record<string, string>>> = {
screenshot: 'Captured', screenshot: 'Captured',
zoom: 'Captured', zoom: 'Captured',
@@ -32,8 +33,8 @@ const RESULT_SUMMARY: Readonly<Partial<Record<string, string>>> = {
hold_key: 'Pressed', hold_key: 'Pressed',
scroll: 'Scrolled', scroll: 'Scrolled',
left_click_drag: 'Dragged', left_click_drag: 'Dragged',
open_application: 'Opened' open_application: 'Opened',
}; }
/** /**
* Rendering overrides for `mcp__computer-use__*` tools. Spread into the MCP * 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`. * Mirror of `getClaudeInChromeMCPToolOverrides`.
*/ */
export function getComputerUseMCPRenderingOverrides(toolName: string): { export function getComputerUseMCPRenderingOverrides(toolName: string): {
userFacingName: () => string; userFacingName: () => string
renderToolUseMessage: (input: Record<string, unknown>, options: { renderToolUseMessage: (
verbose: boolean; input: Record<string, unknown>,
}) => React.ReactNode; options: { verbose: boolean },
renderToolResultMessage: (output: MCPToolResult, progressMessages: unknown[], options: { ) => React.ReactNode
verbose: boolean; renderToolResultMessage: (
}) => React.ReactNode; output: MCPToolResult,
progressMessages: unknown[],
options: { verbose: boolean },
) => React.ReactNode
} { } {
return { return {
userFacingName() { userFacingName() {
return `Computer Use[${toolName}]`; return `Computer Use[${toolName}]`
}, },
// AssistantToolUseMessage.tsx contract: null hides the ENTIRE row, '' shows // AssistantToolUseMessage.tsx contract: null hides the ENTIRE row, '' shows
// the tool name without "(args)". Every path below returns '' when there's // the tool name without "(args)". Every path below returns '' when there's
// nothing to show — never null. // nothing to show — never null.
@@ -64,61 +69,89 @@ export function getComputerUseMCPRenderingOverrides(toolName: string): {
case 'cursor_position': case 'cursor_position':
case 'list_granted_applications': case 'list_granted_applications':
case 'read_clipboard': case 'read_clipboard':
return ''; return ''
case 'left_click': case 'left_click':
case 'right_click': case 'right_click':
case 'middle_click': case 'middle_click':
case 'double_click': case 'double_click':
case 'triple_click': case 'triple_click':
case 'mouse_move': case 'mouse_move':
return fmtCoord(input.coordinate); return fmtCoord(input.coordinate)
case 'left_click_drag': 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': case 'type':
return typeof input.text === 'string' ? `"${truncateToWidth(input.text, 40)}"` : ''; return typeof input.text === 'string'
? `"${truncateToWidth(input.text, 40)}"`
: ''
case 'key': case 'key':
case 'hold_key': case 'hold_key':
return typeof input.text === 'string' ? input.text : ''; return typeof input.text === 'string' ? input.text : ''
case 'scroll': case 'scroll':
return [input.direction, input.amount && `×${input.amount}`, input.coordinate && `at ${fmtCoord(input.coordinate)}`].filter(Boolean).join(' '); return [
case 'zoom': input.direction,
{ input.amount && `×${input.amount}`,
const r = input.region; input.coordinate && `at ${fmtCoord(input.coordinate)}`,
return Array.isArray(r) && r.length === 4 ? `[${r[0]}, ${r[1]}, ${r[2]}, ${r[3]}]` : ''; ]
.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': case 'wait':
return typeof input.duration === 'number' ? `${input.duration}s` : ''; return typeof input.duration === 'number' ? `${input.duration}s` : ''
case 'write_clipboard': case 'write_clipboard':
return typeof input.text === 'string' ? `"${truncateToWidth(input.text, 40)}"` : ''; return typeof input.text === 'string'
? `"${truncateToWidth(input.text, 40)}"`
: ''
case 'open_application': case 'open_application':
return typeof input.bundle_id === 'string' ? String(input.bundle_id) : ''; return typeof input.bundle_id === 'string'
case 'request_access': ? String(input.bundle_id)
{ : ''
const apps = input.apps;
if (!Array.isArray(apps)) return ''; case 'request_access': {
const names = apps.map(a => typeof a?.displayName === 'string' ? a.displayName : '').filter(Boolean); const apps = input.apps
return names.join(', '); if (!Array.isArray(apps)) return ''
const names = apps
.map(a => (typeof a?.displayName === 'string' ? a.displayName : ''))
.filter(Boolean)
return names.join(', ')
} }
case 'computer_batch':
{ case 'computer_batch': {
const actions = input.actions; const actions = input.actions
return Array.isArray(actions) ? `${actions.length} actions` : ''; return Array.isArray(actions) ? `${actions.length} actions` : ''
} }
default: default:
return ''; return ''
} }
}, },
renderToolResultMessage(output, _progress, {
verbose renderToolResultMessage(output, _progress, { verbose }) {
}) { if (verbose || typeof output !== 'object' || output === null) return null
if (verbose || typeof output !== 'object' || output === null) return null;
// Non-verbose: one-line dim summary, like Chrome's pattern. // Non-verbose: one-line dim summary, like Chrome's pattern.
const summary = RESULT_SUMMARY[toolName]; const summary = RESULT_SUMMARY[toolName]
if (!summary) return null; if (!summary) return null
return <MessageResponse height={1}> return (
<MessageResponse height={1}>
<Text dimColor>{summary}</Text> <Text dimColor>{summary}</Text>
</MessageResponse>; </MessageResponse>
)
},
} }
};
} }

View File

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

View File

@@ -1,13 +1,13 @@
import React, { useRef } from 'react'; import React, { useRef } from 'react'
import stripAnsi from 'strip-ansi'; import stripAnsi from 'strip-ansi'
import { Messages } from '../components/Messages.js'; import { Messages } from '../components/Messages.js'
import { KeybindingProvider } from '../keybindings/KeybindingContext.js'; import { KeybindingProvider } from '../keybindings/KeybindingContext.js'
import { loadKeybindingsSyncWithWarnings } from '../keybindings/loadUserBindings.js'; import { loadKeybindingsSyncWithWarnings } from '../keybindings/loadUserBindings.js'
import type { KeybindingContextName } from '../keybindings/types.js'; import type { KeybindingContextName } from '../keybindings/types.js'
import { AppStateProvider } from '../state/AppState.js'; import { AppStateProvider } from '../state/AppState.js'
import type { Tools } from '../Tool.js'; import type { Tools } from '../Tool.js'
import type { Message } from '../types/message.js'; import type { Message } from '../types/message.js'
import { renderToAnsiString } from './staticRender.js'; import { renderToAnsiString } from './staticRender.js'
/** /**
* Minimal keybinding provider for static/headless renders. * 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). * and would hang in headless renders with no stdin).
*/ */
function StaticKeybindingProvider({ function StaticKeybindingProvider({
children children,
}: { }: {
children: React.ReactNode; children: React.ReactNode
}): React.ReactNode { }): React.ReactNode {
const { const { bindings } = loadKeybindingsSyncWithWarnings()
bindings const pendingChordRef = useRef(null)
} = loadKeybindingsSyncWithWarnings(); const handlerRegistryRef = useRef(new Map())
const pendingChordRef = useRef(null); const activeContexts = useRef(new Set<KeybindingContextName>()).current
const handlerRegistryRef = useRef(new Map());
const activeContexts = useRef(new Set<KeybindingContextName>()).current; return (
return <KeybindingProvider bindings={bindings} pendingChordRef={pendingChordRef} pendingChord={null} setPendingChord={() => {}} activeContexts={activeContexts} registerActiveContext={() => {}} unregisterActiveContext={() => {}} handlerRegistryRef={handlerRegistryRef}> <KeybindingProvider
bindings={bindings}
pendingChordRef={pendingChordRef}
pendingChord={null}
setPendingChord={() => {}}
activeContexts={activeContexts}
registerActiveContext={() => {}}
unregisterActiveContext={() => {}}
handlerRegistryRef={handlerRegistryRef}
>
{children} {children}
</KeybindingProvider>; </KeybindingProvider>
)
} }
// Upper-bound how many NormalizedMessages a Message can produce. // 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. // NormalizedMessages — 1:1 with block count. String content = 1 block.
// AttachmentMessage etc. have no .message and normalize to ≤1. // AttachmentMessage etc. have no .message and normalize to ≤1.
function normalizedUpperBound(m: Message): number { function normalizedUpperBound(m: Message): number {
if (!('message' in m)) return 1; if (!('message' in m)) return 1
const c = m.message.content; const c = m.message.content
return Array.isArray(c) ? c.length : 1; 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 * the full normalized array so tool_use↔tool_result resolves regardless of
* which chunk each landed in. * which chunk each landed in.
*/ */
export async function streamRenderedMessages(messages: Message[], tools: Tools, sink: (ansiChunk: string) => void | Promise<void>, { export async function streamRenderedMessages(
messages: Message[],
tools: Tools,
sink: (ansiChunk: string) => void | Promise<void>,
{
columns, columns,
verbose = false, verbose = false,
chunkSize = 40, chunkSize = 40,
onProgress onProgress,
}: { }: {
columns?: number; columns?: number
verbose?: boolean; verbose?: boolean
chunkSize?: number; chunkSize?: number
onProgress?: (rendered: number) => void; onProgress?: (rendered: number) => void
} = {}): Promise<void> { } = {},
const renderChunk = (range: readonly [number, number]) => renderToAnsiString(<AppStateProvider> ): Promise<void> {
const renderChunk = (range: readonly [number, number]) =>
renderToAnsiString(
<AppStateProvider>
<StaticKeybindingProvider> <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> </StaticKeybindingProvider>
</AppStateProvider>, columns); </AppStateProvider>,
columns,
)
// renderRange indexes into the post-collapse array whose length we can't // renderRange indexes into the post-collapse array whose length we can't
// see from here — normalize splits each Message into one NormalizedMessage // see from here — normalize splits each Message into one NormalizedMessage
// per content block (unbounded per message), collapse merges some back. // per content block (unbounded per message), collapse merges some back.
// Ceiling is the exact normalize output count + chunkSize so the loop // Ceiling is the exact normalize output count + chunkSize so the loop
// always reaches the empty slice where break fires (collapse only shrinks). // always reaches the empty slice where break fires (collapse only shrinks).
let ceiling = chunkSize; let ceiling = chunkSize
for (const m of messages) ceiling += normalizedUpperBound(m); for (const m of messages) ceiling += normalizedUpperBound(m)
for (let offset = 0; offset < ceiling; offset += chunkSize) { for (let offset = 0; offset < ceiling; offset += chunkSize) {
const ansi = await renderChunk([offset, offset + chunkSize]); const ansi = await renderChunk([offset, offset + chunkSize])
if (stripAnsi(ansi).trim() === '') break; if (stripAnsi(ansi).trim() === '') break
await sink(ansi); await sink(ansi)
onProgress?.(offset + chunkSize); 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. * Renders messages to a plain text string suitable for export.
* Uses the same React rendering logic as the interactive UI. * Uses the same React rendering logic as the interactive UI.
*/ */
export async function renderMessagesToPlainText(messages: Message[], tools: Tools = [], columns?: number): Promise<string> { export async function renderMessagesToPlainText(
const parts: string[] = []; messages: Message[],
await streamRenderedMessages(messages, tools, chunk => void parts.push(stripAnsi(chunk)), { tools: Tools = [],
columns columns?: number,
}); ): Promise<string> {
return parts.join(''); 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 * as React from 'react'
import { Text } from '../ink.js'; import { Text } from '../ink.js'
/** /**
* Inverse-highlight every occurrence of `query` in `text` (case-insensitive). * Inverse-highlight every occurrence of `query` in `text` (case-insensitive).
@@ -7,21 +7,23 @@ import { Text } from '../ink.js';
* and preview panes. * and preview panes.
*/ */
export function highlightMatch(text: string, query: string): React.ReactNode { export function highlightMatch(text: string, query: string): React.ReactNode {
if (!query) return text; if (!query) return text
const queryLower = query.toLowerCase(); const queryLower = query.toLowerCase()
const textLower = text.toLowerCase(); const textLower = text.toLowerCase()
const parts: React.ReactNode[] = []; const parts: React.ReactNode[] = []
let offset = 0; let offset = 0
let idx = textLower.indexOf(queryLower, offset); let idx = textLower.indexOf(queryLower, offset)
if (idx === -1) return text; if (idx === -1) return text
while (idx !== -1) { while (idx !== -1) {
if (idx > offset) parts.push(text.slice(offset, idx)); if (idx > offset) parts.push(text.slice(offset, idx))
parts.push(<Text key={idx} inverse> parts.push(
<Text key={idx} inverse>
{text.slice(idx, idx + query.length)} {text.slice(idx, idx + query.length)}
</Text>); </Text>,
offset = idx + query.length; )
idx = textLower.indexOf(queryLower, offset); offset = idx + query.length
idx = textLower.indexOf(queryLower, offset)
} }
if (offset < text.length) parts.push(text.slice(offset)); if (offset < text.length) parts.push(text.slice(offset))
return <>{parts}</>; return <>{parts}</>
} }

View File

@@ -1,10 +1,14 @@
import { performBackgroundPluginInstallations } from '../../services/plugins/PluginInstallationManager.js'; import { performBackgroundPluginInstallations } from '../../services/plugins/PluginInstallationManager.js'
import type { AppState } from '../../state/AppState.js'; import type { AppState } from '../../state/AppState.js'
import { checkHasTrustDialogAccepted } from '../config.js'; import { checkHasTrustDialogAccepted } from '../config.js'
import { logForDebugging } from '../debug.js'; import { logForDebugging } from '../debug.js'
import { clearMarketplacesCache, registerSeedMarketplaces } from './marketplaceManager.js'; import {
import { clearPluginCache } from './pluginLoader.js'; clearMarketplacesCache,
type SetAppState = (f: (prevState: AppState) => AppState) => void; registerSeedMarketplaces,
} from './marketplaceManager.js'
import { clearPluginCache } from './pluginLoader.js'
type SetAppState = (f: (prevState: AppState) => AppState) => void
/** /**
* Perform plugin startup checks and initiate background installations * 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 * @param setAppState Function to update app state with installation progress
*/ */
export async function performStartupChecks(setAppState: SetAppState): Promise<void> { export async function performStartupChecks(
logForDebugging('performStartupChecks called'); setAppState: SetAppState,
): Promise<void> {
logForDebugging('performStartupChecks called')
// Check if the current directory has been trusted // Check if the current directory has been trusted
if (!checkHasTrustDialogAccepted()) { if (!checkHasTrustDialogAccepted()) {
logForDebugging('Trust not accepted for current directory - skipping plugin installations'); logForDebugging(
return; 'Trust not accepted for current directory - skipping plugin installations',
)
return
} }
try { try {
logForDebugging('Starting background plugin installations'); logForDebugging('Starting background plugin installations')
// Register seed marketplaces (CLAUDE_CODE_PLUGIN_SEED_DIR) before diffing. // Register seed marketplaces (CLAUDE_CODE_PLUGIN_SEED_DIR) before diffing.
// Idempotent; no-op if seed not configured. Without this, background install // 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 // If registration changed state, clear caches so earlier plugin-load passes
// (e.g. getAllMcpConfigs during REPL init) don't keep stale "marketplace // (e.g. getAllMcpConfigs during REPL init) don't keep stale "marketplace
// not found" results. // not found" results.
const seedChanged = await registerSeedMarketplaces(); const seedChanged = await registerSeedMarketplaces()
if (seedChanged) { if (seedChanged) {
clearMarketplacesCache(); clearMarketplacesCache()
clearPluginCache('performStartupChecks: seed marketplaces changed'); clearPluginCache('performStartupChecks: seed marketplaces changed')
// Set needsRefresh so useManagePlugins notifies the user to run // Set needsRefresh so useManagePlugins notifies the user to run
// /reload-plugins. Without this signal, the initial plugin-load // /reload-plugins. Without this signal, the initial plugin-load
// (which raced and cached "marketplace not found") would persist // (which raced and cached "marketplace not found") would persist
// until the user manually reloads. // until the user manually reloads.
setAppState(prev => { setAppState(prev => {
if (prev.plugins.needsRefresh) return prev; if (prev.plugins.needsRefresh) return prev
return { return {
...prev, ...prev,
plugins: { plugins: { ...prev.plugins, needsRefresh: true },
...prev.plugins,
needsRefresh: true
} }
}; })
});
} }
// Start background installations without waiting // Start background installations without waiting
// This will update AppState as installations progress // This will update AppState as installations progress
await performBackgroundPluginInstallations(setAppState); await performBackgroundPluginInstallations(setAppState)
} catch (error) { } catch (error) {
// Even if something fails here, don't block startup // 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 axios from 'axios'
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react'
import { logEvent } from 'src/services/analytics/index.js'; import { logEvent } from 'src/services/analytics/index.js'
import { Spinner } from '../components/Spinner.js'; import { Spinner } from '../components/Spinner.js'
import { getOauthConfig } from '../constants/oauth.js'; import { getOauthConfig } from '../constants/oauth.js'
import { useTimeout } from '../hooks/useTimeout.js'; import { useTimeout } from '../hooks/useTimeout.js'
import { Box, Text } from '../ink.js'; import { Box, Text } from '../ink.js'
import { getSSLErrorHint } from '../services/api/errorUtils.js'; import { getSSLErrorHint } from '../services/api/errorUtils.js'
import { getUserAgent } from './http.js'; import { getUserAgent } from './http.js'
import { logError } from './log.js'; import { logError } from './log.js'
export interface PreflightCheckResult { export interface PreflightCheckResult {
success: boolean; success: boolean
error?: string; error?: string
sslHint?: string; sslHint?: string
} }
async function checkEndpoints(): Promise<PreflightCheckResult> { async function checkEndpoints(): Promise<PreflightCheckResult> {
try { try {
const oauthConfig = getOauthConfig(); const oauthConfig = getOauthConfig()
const tokenUrl = new URL(oauthConfig.TOKEN_URL); const tokenUrl = new URL(oauthConfig.TOKEN_URL)
const endpoints = [`${oauthConfig.BASE_API_URL}/api/hello`, `${tokenUrl.origin}/v1/oauth/hello`]; const endpoints = [
const checkEndpoint = async (url: string): Promise<PreflightCheckResult> => { `${oauthConfig.BASE_API_URL}/api/hello`,
`${tokenUrl.origin}/v1/oauth/hello`,
]
const checkEndpoint = async (
url: string,
): Promise<PreflightCheckResult> => {
try { try {
const response = await axios.get(url, { const response = await axios.get(url, {
headers: { headers: { 'User-Agent': getUserAgent() },
'User-Agent': getUserAgent() })
}
});
if (response.status !== 200) { if (response.status !== 200) {
const hostname = new URL(url).hostname; const hostname = new URL(url).hostname
return { return {
success: false, 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) { } catch (error) {
const hostname = new URL(url).hostname; const hostname = new URL(url).hostname
const sslHint = getSSLErrorHint(error); const sslHint = getSSLErrorHint(error)
return { return {
success: false, success: false,
error: `Failed to connect to ${hostname}: ${error instanceof Error ? (error as ErrnoException).code || error.message : String(error)}`, 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) { if (failedResult) {
// Log failure to Statsig // Log failure to Statsig
logEvent('tengu_preflight_check_failed', { logEvent('tengu_preflight_check_failed', {
isConnectivityError: false, isConnectivityError: false,
hasErrorMessage: !!failedResult.error, hasErrorMessage: !!failedResult.error,
isSSLError: !!failedResult.sslHint isSSLError: !!failedResult.sslHint,
}); })
} }
return failedResult || {
success: true return failedResult || { success: true }
};
} catch (error) { } catch (error) {
logError(error as Error); logError(error as Error)
// Log to Statsig // Log to Statsig
logEvent('tengu_preflight_check_failed', { logEvent('tengu_preflight_check_failed', {
isConnectivityError: true isConnectivityError: true,
}); })
return { return {
success: false, 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 { interface PreflightStepProps {
onSuccess: () => void; onSuccess: () => void
} }
export function PreflightStep({ onSuccess }: PreflightStepProps) {
const [result, setResult] = useState<PreflightCheckResult | null>(null); export function PreflightStep({
const [isChecking, setIsChecking] = useState(true); onSuccess,
const showSpinner = useTimeout(1000) && isChecking; }: 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(() => { useEffect(() => {
checkEndpoints().then((checkResult) => { async function run() {
setResult(checkResult); const checkResult = await checkEndpoints()
setIsChecking(false); setResult(checkResult)
}); setIsChecking(false)
}, []); }
void run()
}, [])
useEffect(() => { useEffect(() => {
if (result?.success) { if (result?.success) {
onSuccess(); onSuccess()
} else if (result && !result.success) { } else if (result && !result.success) {
logError(result.error ?? 'Preflight connectivity check failed'); const timer = setTimeout(() => process.exit(1), 100)
onSuccess(); return () => clearTimeout(timer)
} }
}, [result, onSuccess]); }, [result, onSuccess])
let content = null; return (
if (isChecking && showSpinner) { <Box flexDirection="column" gap={1} paddingLeft={1}>
content = ( {isChecking && showSpinner ? (
<Box paddingLeft={1}> <Box paddingLeft={1}>
<Spinner /> <Spinner />
<Text>Checking connectivity...</Text> <Text>Checking connectivity...</Text>
</Box> </Box>
); ) : (
} else if (!result?.success && !isChecking) { !result?.success &&
content = ( !isChecking && (
<Box flexDirection="column" gap={1}> <Box flexDirection="column" gap={1}>
<Text color="error">Unable to connect to Anthropic services</Text> <Text color="error">Unable to connect to Anthropic services</Text>
<Text color="error">{result?.error}</Text> <Text color="error">{result?.error}</Text>
{result?.sslHint ? ( {result?.sslHint ? (
<Box flexDirection="column" gap={1}> <Box flexDirection="column" gap={1}>
<Text>{result.sslHint}</Text> <Text>{result.sslHint}</Text>
<Text color="suggestion">See https://code.claude.com/docs/en/network-config</Text> <Text color="suggestion">
See https://code.claude.com/docs/en/network-config
</Text>
</Box> </Box>
) : ( ) : (
<Box flexDirection="column" gap={1}> <Box flexDirection="column" gap={1}>
<Text>Please check your internet connection and network settings.</Text>
<Text> <Text>
Note: Claude Code might not be available in your country. Check supported countries at{" "} Please check your internet connection and network settings.
<Text color="suggestion">https://anthropic.com/supported-countries</Text> </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> </Text>
</Box> </Box>
)} )}
</Box> </Box>
); )
} )}
return (
<Box flexDirection="column" gap={1} paddingLeft={1}>
{content}
</Box> </Box>
); )
} }

View File

@@ -1,69 +1,97 @@
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources'; import type { ContentBlockParam } from '@anthropic-ai/sdk/resources'
import { randomUUID } from 'crypto'; import { randomUUID } from 'crypto'
import * as React from 'react'; import * as React from 'react'
import { BashModeProgress } from 'src/components/BashModeProgress.js'; import { BashModeProgress } from 'src/components/BashModeProgress.js'
import type { SetToolJSXFn } from 'src/Tool.js'; import type { SetToolJSXFn } from 'src/Tool.js'
import { BashTool } from 'src/tools/BashTool/BashTool.js'; import { BashTool } from 'src/tools/BashTool/BashTool.js'
import type { AttachmentMessage, SystemMessage, UserMessage } from 'src/types/message.js'; import type {
import type { ShellProgress } from 'src/types/tools.js'; AttachmentMessage,
import { logEvent } from '../../services/analytics/index.js'; SystemMessage,
import { errorMessage, ShellError } from '../errors.js'; UserMessage,
import { createSyntheticUserCaveatMessage, createUserInterruptionMessage, createUserMessage, prepareUserContent } from '../messages.js'; } from 'src/types/message.js'
import { resolveDefaultShell } from '../shell/resolveDefaultShell.js'; import type { ShellProgress } from 'src/types/tools.js'
import { isPowerShellToolEnabled } from '../shell/shellToolUtils.js'; import { logEvent } from '../../services/analytics/index.js'
import { processToolResultBlock } from '../toolResultStorage.js'; import { errorMessage, ShellError } from '../errors.js'
import { escapeXml } from '../xml.js'; import {
import type { ProcessUserInputContext } from './processUserInput.js'; createSyntheticUserCaveatMessage,
export async function processBashCommand(inputString: string, precedingInputBlocks: ContentBlockParam[], attachmentMessages: AttachmentMessage[], context: ProcessUserInputContext, setToolJSX: SetToolJSXFn): Promise<{ createUserInterruptionMessage,
messages: (UserMessage | AttachmentMessage | SystemMessage)[]; createUserMessage,
shouldQuery: boolean; 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 // Shell routing (docs/design/ps-shell-selection.md §5.2): consult
// defaultShell, fall back to bash. isPowerShellToolEnabled() applies the // defaultShell, fall back to bash. isPowerShellToolEnabled() applies the
// same platform + env-var gate as tools.ts so input-box routing matches // same platform + env-var gate as tools.ts so input-box routing matches
// tool-list visibility. Computed up front so telemetry records the // tool-list visibility. Computed up front so telemetry records the
// actual shell, not the raw setting. // actual shell, not the raw setting.
const usePowerShell = isPowerShellToolEnabled() && resolveDefaultShell() === 'powershell'; const usePowerShell =
logEvent('tengu_input_bash', { isPowerShellToolEnabled() && resolveDefaultShell() === 'powershell'
powershell: usePowerShell
}); logEvent('tengu_input_bash', { powershell: usePowerShell })
const userMessage = createUserMessage({ const userMessage = createUserMessage({
content: prepareUserContent({ content: prepareUserContent({
inputString: `<bash-input>${inputString}</bash-input>`, inputString: `<bash-input>${inputString}</bash-input>`,
precedingInputBlocks precedingInputBlocks,
}),
}) })
});
// ctrl+b to background indicator // ctrl+b to background indicator
let jsx: React.ReactNode; let jsx: React.ReactNode
// Just show initial UI // Just show initial UI
setToolJSX({ setToolJSX({
jsx: <BashModeProgress input={inputString} progress={null} verbose={context.options.verbose} />, jsx: (
shouldHidePromptInput: false <BashModeProgress
}); input={inputString}
progress={null}
verbose={context.options.verbose}
/>
),
shouldHidePromptInput: false,
})
try { try {
const bashModeContext: ProcessUserInputContext = { const bashModeContext: ProcessUserInputContext = {
...context, ...context,
// TODO: Clean up this hack // TODO: Clean up this hack
setToolJSX: _ => { setToolJSX: _ => {
jsx = _?.jsx; jsx = _?.jsx
},
} }
};
// Progress UI — shared across both shell backends (both emit ShellProgress) // Progress UI — shared across both shell backends (both emit ShellProgress)
const onProgress = (progress: { const onProgress = (progress: { data: ShellProgress }) => {
data: ShellProgress;
}) => {
setToolJSX({ setToolJSX({
jsx: <> jsx: (
<BashModeProgress input={inputString!} progress={progress.data} verbose={context.options.verbose} /> <>
<BashModeProgress
input={inputString!}
progress={progress.data}
verbose={context.options.verbose}
/>
{jsx} {jsx}
</>, </>
),
shouldHidePromptInput: false, shouldHidePromptInput: false,
showSpinner: false showSpinner: false,
}); })
}; }
// User-initiated `!` commands run outside sandbox. Both shell tools honor // User-initiated `!` commands run outside sandbox. Both shell tools honor
// dangerouslyDisableSandbox (checked against areUnsandboxedCommandsAllowed() // dangerouslyDisableSandbox (checked against areUnsandboxedCommandsAllowed()
@@ -71,69 +99,107 @@ export async function processBashCommand(inputString: string, precedingInputBloc
// native, shouldUseSandbox() returns false regardless (unsupported platform). // native, shouldUseSandbox() returns false regardless (unsupported platform).
// Lazy-require PowerShellTool so its ~300KB chunk only loads when the // Lazy-require PowerShellTool so its ~300KB chunk only loads when the
// user has actually selected the powershell default shell. // user has actually selected the powershell default shell.
type PSMod = typeof import('src/tools/PowerShellTool/PowerShellTool.js'); type PSMod = typeof import('src/tools/PowerShellTool/PowerShellTool.js')
let PowerShellTool: PSMod['PowerShellTool'] | null = null; let PowerShellTool: PSMod['PowerShellTool'] | null = null
if (usePowerShell) { if (usePowerShell) {
/* eslint-disable @typescript-eslint/no-require-imports */ /* 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 */ /* eslint-enable @typescript-eslint/no-require-imports */
} }
const shellTool = PowerShellTool ?? BashTool; const shellTool = PowerShellTool ?? BashTool
const response = PowerShellTool ? await PowerShellTool.call({
const response = PowerShellTool
? await PowerShellTool.call(
{ command: inputString, dangerouslyDisableSandbox: true },
bashModeContext,
undefined,
undefined,
onProgress,
)
: await BashTool.call(
{
command: inputString, command: inputString,
dangerouslyDisableSandbox: true dangerouslyDisableSandbox: true,
}, bashModeContext, undefined, undefined, onProgress) : await BashTool.call({ },
command: inputString, bashModeContext,
dangerouslyDisableSandbox: true undefined,
}, bashModeContext, undefined, undefined, onProgress); undefined,
const data = response.data; onProgress,
)
const data = response.data
if (!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) // Reuse the same formatting pipeline as inline !`cmd` bash (promptShellExecution)
// and model-initiated Bash. When BashTool.call() persists large output to disk, // and model-initiated Bash. When BashTool.call() persists large output to disk,
// data.persistedOutputPath is set and the formatter wraps in <persisted-output>. // data.persistedOutputPath is set and the formatter wraps in <persisted-output>.
// Pass stderr:'' to keep it separate for the <bash-stderr> UI tag. // Pass stderr:'' to keep it separate for the <bash-stderr> UI tag.
const mapped = await processToolResultBlock(shellTool, { const mapped = await processToolResultBlock(
...data, shellTool,
stderr: '' { ...data, stderr: '' },
}, randomUUID()); randomUUID(),
)
// mapped.content may contain our own <persisted-output> wrapper (trusted // mapped.content may contain our own <persisted-output> wrapper (trusted
// XML from buildLargeToolResultMessage). Escaping it would turn structural // XML from buildLargeToolResultMessage). Escaping it would turn structural
// tags into &lt;persisted-output&gt;, breaking the model's parse and // tags into &lt;persisted-output&gt;, breaking the model's parse and
// UserBashOutputMessage's extractTag. Escape the raw fallback only. // 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 { return {
messages: [createSyntheticUserCaveatMessage(), userMessage, ...attachmentMessages, createUserMessage({ messages: [
content: `<bash-stdout>${stdout}</bash-stdout><bash-stderr>${escapeXml(stderr)}</bash-stderr>` createSyntheticUserCaveatMessage(),
})], userMessage,
shouldQuery: false ...attachmentMessages,
}; createUserMessage({
content: `<bash-stdout>${stdout}</bash-stdout><bash-stderr>${escapeXml(stderr)}</bash-stderr>`,
}),
],
shouldQuery: false,
}
} catch (e) { } catch (e) {
if (e instanceof ShellError) { if (e instanceof ShellError) {
if (e.interrupted) { if (e.interrupted) {
return { return {
messages: [createSyntheticUserCaveatMessage(), userMessage, createUserInterruptionMessage({ messages: [
toolUse: false createSyntheticUserCaveatMessage(),
}), ...attachmentMessages], userMessage,
shouldQuery: false createUserInterruptionMessage({ toolUse: false }),
}; ...attachmentMessages,
],
shouldQuery: false,
}
} }
return { return {
messages: [createSyntheticUserCaveatMessage(), userMessage, ...attachmentMessages, createUserMessage({ messages: [
content: `<bash-stdout>${escapeXml(e.stdout)}</bash-stdout><bash-stderr>${escapeXml(e.stderr)}</bash-stderr>` createSyntheticUserCaveatMessage(),
})], userMessage,
shouldQuery: false ...attachmentMessages,
}; createUserMessage({
content: `<bash-stdout>${escapeXml(e.stdout)}</bash-stdout><bash-stderr>${escapeXml(e.stderr)}</bash-stderr>`,
}),
],
shouldQuery: false,
}
} }
return { return {
messages: [createSyntheticUserCaveatMessage(), userMessage, ...attachmentMessages, createUserMessage({ messages: [
content: `<bash-stderr>Command failed: ${escapeXml(errorMessage(e))}</bash-stderr>` createSyntheticUserCaveatMessage(),
})], userMessage,
shouldQuery: false ...attachmentMessages,
}; createUserMessage({
content: `<bash-stderr>Command failed: ${escapeXml(errorMessage(e))}</bash-stderr>`,
}),
],
shouldQuery: false,
}
} finally { } 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 * as React from 'react'; import { useLayoutEffect } from 'react'
import { useLayoutEffect } from 'react'; import { PassThrough } from 'stream'
import { PassThrough } from 'stream'; import stripAnsi from 'strip-ansi'
import stripAnsi from 'strip-ansi'; import { render, useApp } from '../ink.js'
import { render, useApp } from '../ink.js';
// This is a workaround for the fact that Ink doesn't support multiple <Static> // 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 // 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 * before exiting. This is more robust than process.nextTick() for React 19's
* async render cycle. * async render cycle.
*/ */
function RenderOnceAndExit(t0) { function RenderOnceAndExit({
const $ = _c(5); children,
const { }: {
children children: React.ReactNode
} = t0; }): React.ReactNode {
const { const { exit } = useApp()
exit
} = useApp(); // useLayoutEffect runs synchronously after React commits DOM mutations.
let t1; // setTimeout(0) defers exit to allow Ink to flush output to the stream.
let t2; useLayoutEffect(() => {
if ($[0] !== exit) { const timer = setTimeout(exit, 0)
t1 = () => { return () => clearTimeout(timer)
const timer = setTimeout(exit, 0); }, [exit])
return () => clearTimeout(timer);
}; return <>{children}</>
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;
} }
// DEC synchronized update markers used by terminals // DEC synchronized update markers used by terminals
const SYNC_START = '\x1B[?2026h'; const SYNC_START = '\x1B[?2026h'
const SYNC_END = '\x1B[?2026l'; const SYNC_END = '\x1B[?2026l'
/** /**
* Extracts content from the first complete frame in Ink's output. * 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. * update sequences ([?2026h ... [?2026l). We only want the first frame's content.
*/ */
function extractFirstFrame(output: string): string { function extractFirstFrame(output: string): string {
const startIndex = output.indexOf(SYNC_START); const startIndex = output.indexOf(SYNC_START)
if (startIndex === -1) return output; if (startIndex === -1) return output
const contentStart = startIndex + SYNC_START.length;
const endIndex = output.indexOf(SYNC_END, contentStart); const contentStart = startIndex + SYNC_START.length
if (endIndex === -1) return output; const endIndex = output.indexOf(SYNC_END, contentStart)
return output.slice(contentStart, endIndex); if (endIndex === -1) return output
return output.slice(contentStart, endIndex)
} }
/** /**
* Renders a React node to a string with ANSI escape codes (for terminal output). * 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 => { return new Promise(async resolve => {
let output = ''; let output = ''
// Capture all writes. Set .columns so Ink (ink.tsx:~165) picks up a // Capture all writes. Set .columns so Ink (ink.tsx:~165) picks up a
// chosen width instead of PassThrough's undefined → 80 fallback — // chosen width instead of PassThrough's undefined → 80 fallback —
// useful for rendering at terminal width for file dumps that should // useful for rendering at terminal width for file dumps that should
// match what the user sees on screen. // match what the user sees on screen.
const stream = new PassThrough(); const stream = new PassThrough()
if (columns !== undefined) { if (columns !== undefined) {
; ;(stream as unknown as { columns: number }).columns = columns
(stream as unknown as {
columns: number;
}).columns = columns;
} }
stream.on('data', chunk => { stream.on('data', chunk => {
output += chunk.toString(); output += chunk.toString()
}); })
// Render the component wrapped in RenderOnceAndExit // Render the component wrapped in RenderOnceAndExit
// Non-TTY stdout (PassThrough) gives full-frame output instead of diffs // Non-TTY stdout (PassThrough) gives full-frame output instead of diffs
const instance = await render(<RenderOnceAndExit>{node}</RenderOnceAndExit>, { const instance = await render(
<RenderOnceAndExit>{node}</RenderOnceAndExit>,
{
stdout: stream as unknown as NodeJS.WriteStream, stdout: stream as unknown as NodeJS.WriteStream,
patchConsole: false patchConsole: false,
}); },
)
// Wait for the component to exit naturally // Wait for the component to exit naturally
await instance.waitUntilExit(); await instance.waitUntilExit()
// Extract only the first frame's content to avoid duplication // Extract only the first frame's content to avoid duplication
// (Ink outputs multiple frames in non-TTY mode) // (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). * Renders a React node to a plain text string (ANSI codes stripped).
*/ */
export async function renderToString(node: React.ReactNode, columns?: number): Promise<string> { export async function renderToString(
const output = await renderToAnsiString(node, columns); node: React.ReactNode,
return stripAnsi(output); columns?: number,
): Promise<string> {
const output = await renderToAnsiString(node, columns)
return stripAnsi(output)
} }

View File

@@ -1,361 +1,472 @@
import chalk from 'chalk'; import chalk from 'chalk'
import figures from 'figures'; import figures from 'figures'
import * as React from 'react'; import * as React from 'react'
import { color, Text } from '../ink.js'; import { color, Text } from '../ink.js'
import type { MCPServerConnection } from '../services/mcp/types.js'; import type { MCPServerConnection } from '../services/mcp/types.js'
import { getAccountInformation, isClaudeAISubscriber } from './auth.js'; import { getAccountInformation, isClaudeAISubscriber } from './auth.js'
import { getLargeMemoryFiles, getMemoryFiles, MAX_MEMORY_CHARACTER_COUNT } from './claudemd.js'; import {
import { getDoctorDiagnostic } from './doctorDiagnostic.js'; getLargeMemoryFiles,
import { getAWSRegion, getDefaultVertexRegion, isEnvTruthy } from './envUtils.js'; getMemoryFiles,
import { getDisplayPath } from './file.js'; MAX_MEMORY_CHARACTER_COUNT,
import { formatNumber } from './format.js'; } from './claudemd.js'
import { getIdeClientName, type IDEExtensionInstallationStatus, isJetBrainsIde, toIDEDisplayName } from './ide.js'; import { getDoctorDiagnostic } from './doctorDiagnostic.js'
import { getClaudeAiUserDefaultModelDescription, modelDisplayString } from './model/model.js'; import {
import { getAPIProvider } from './model/providers.js'; getAWSRegion,
import { getMTLSConfig } from './mtls.js'; getDefaultVertexRegion,
import { checkInstall } from './nativeInstaller/index.js'; isEnvTruthy,
import { getProxyUrl } from './proxy.js'; } from './envUtils.js'
import { SandboxManager } from './sandbox/sandbox-adapter.js'; import { getDisplayPath } from './file.js'
import { getSettingsWithAllErrors } from './settings/allErrors.js'; import { formatNumber } from './format.js'
import { getEnabledSettingSources, getSettingSourceDisplayNameCapitalized } from './settings/constants.js'; import {
import { getManagedFileSettingsPresence, getPolicySettingsOrigin, getSettingsForSource } from './settings/settings.js'; getIdeClientName,
import type { ThemeName } from './theme.js'; 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 = { export type Property = {
label?: string; label?: string
value: React.ReactNode | Array<string>; value: React.ReactNode | Array<string>
}; }
export type Diagnostic = React.ReactNode;
export type Diagnostic = React.ReactNode
export function buildSandboxProperties(): Property[] { export function buildSandboxProperties(): Property[] {
if ((process.env.USER_TYPE) !== 'ant') { if (process.env.USER_TYPE !== 'ant') {
return []; return []
} }
const isSandboxed = SandboxManager.isSandboxingEnabled();
return [{ const isSandboxed = SandboxManager.isSandboxingEnabled()
return [
{
label: 'Bash Sandbox', label: 'Bash Sandbox',
value: isSandboxed ? 'Enabled' : 'Disabled' value: isSandboxed ? 'Enabled' : 'Disabled',
}]; },
]
} }
export function buildIDEProperties(mcpClients: MCPServerConnection[], ideInstallationStatus: IDEExtensionInstallationStatus | null = null, theme: ThemeName): Property[] {
const ideClient = mcpClients?.find(client => client.name === 'ide'); export function buildIDEProperties(
mcpClients: MCPServerConnection[],
ideInstallationStatus: IDEExtensionInstallationStatus | null = null,
theme: ThemeName,
): Property[] {
const ideClient = mcpClients?.find(client => client.name === 'ide')
if (ideInstallationStatus) { if (ideInstallationStatus) {
const ideName = toIDEDisplayName(ideInstallationStatus.ideType); const ideName = toIDEDisplayName(ideInstallationStatus.ideType)
const pluginOrExtension = isJetBrainsIde(ideInstallationStatus.ideType) ? 'plugin' : 'extension'; const pluginOrExtension = isJetBrainsIde(ideInstallationStatus.ideType)
? 'plugin'
: 'extension'
if (ideInstallationStatus.error) { if (ideInstallationStatus.error) {
return [{ return [
{
label: 'IDE', label: 'IDE',
value: <Text> value: (
<Text>
{color('error', theme)(figures.cross)} Error installing {ideName}{' '} {color('error', theme)(figures.cross)} Error installing {ideName}{' '}
{pluginOrExtension}: {ideInstallationStatus.error} {pluginOrExtension}: {ideInstallationStatus.error}
{'\n'}Please restart your IDE and try again. {'\n'}Please restart your IDE and try again.
</Text> </Text>
}]; ),
},
]
} }
if (ideInstallationStatus.installed) { if (ideInstallationStatus.installed) {
if (ideClient && ideClient.type === 'connected') { if (ideClient && ideClient.type === 'connected') {
if (ideInstallationStatus.installedVersion !== ideClient.serverInfo?.version) { if (
return [{ ideInstallationStatus.installedVersion !==
ideClient.serverInfo?.version
) {
return [
{
label: 'IDE', label: 'IDE',
value: `Connected to ${ideName} ${pluginOrExtension} version ${ideInstallationStatus.installedVersion} (server version: ${ideClient.serverInfo?.version})` value: `Connected to ${ideName} ${pluginOrExtension} version ${ideInstallationStatus.installedVersion} (server version: ${ideClient.serverInfo?.version})`,
}]; },
]
} else { } else {
return [{ return [
{
label: 'IDE', label: 'IDE',
value: `Connected to ${ideName} ${pluginOrExtension} version ${ideInstallationStatus.installedVersion}` value: `Connected to ${ideName} ${pluginOrExtension} version ${ideInstallationStatus.installedVersion}`,
}]; },
]
} }
} else { } else {
return [{ return [
{
label: 'IDE', label: 'IDE',
value: `Installed ${ideName} ${pluginOrExtension}` value: `Installed ${ideName} ${pluginOrExtension}`,
}]; },
]
} }
} }
} else if (ideClient) { } else if (ideClient) {
const ideName = getIdeClientName(ideClient) ?? 'IDE'; const ideName = getIdeClientName(ideClient) ?? 'IDE'
if (ideClient.type === 'connected') { if (ideClient.type === 'connected') {
return [{ return [
{
label: 'IDE', label: 'IDE',
value: `Connected to ${ideName} extension` value: `Connected to ${ideName} extension`,
}]; },
]
} else { } else {
return [{ return [
{
label: 'IDE', label: 'IDE',
value: `${color('error', theme)(figures.cross)} Not connected to ${ideName}` 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) { if (!servers.length) {
return []; return []
} }
// Summary instead of a full server list — 20+ servers wrapped onto many // Summary instead of a full server list — 20+ servers wrapped onto many
// rows, dominating the Status pane. Show counts by state + /mcp hint. // rows, dominating the Status pane. Show counts by state + /mcp hint.
const byState = { const byState = { connected: 0, pending: 0, needsAuth: 0, failed: 0 }
connected: 0,
pending: 0,
needsAuth: 0,
failed: 0
};
for (const s of servers) { 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[] = []; const parts: string[] = []
if (byState.connected) parts.push(color('success', theme)(`${byState.connected} connected`)); if (byState.connected)
if (byState.needsAuth) parts.push(color('warning', theme)(`${byState.needsAuth} need auth`)); parts.push(color('success', theme)(`${byState.connected} connected`))
if (byState.pending) parts.push(color('inactive', theme)(`${byState.pending} pending`)); if (byState.needsAuth)
if (byState.failed) parts.push(color('error', theme)(`${byState.failed} failed`)); parts.push(color('warning', theme)(`${byState.needsAuth} need auth`))
return [{ 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', label: 'MCP servers',
value: `${parts.join(', ')} ${color('inactive', theme)('· /mcp')}` value: `${parts.join(', ')} ${color('inactive', theme)('· /mcp')}`,
}]; },
]
} }
export async function buildMemoryDiagnostics(): Promise<Diagnostic[]> { export async function buildMemoryDiagnostics(): Promise<Diagnostic[]> {
const files = await getMemoryFiles(); const files = await getMemoryFiles()
const largeFiles = getLargeMemoryFiles(files); const largeFiles = getLargeMemoryFiles(files)
const diagnostics: Diagnostic[] = [];
const diagnostics: Diagnostic[] = []
largeFiles.forEach(file => { largeFiles.forEach(file => {
const displayPath = getDisplayPath(file.path); const displayPath = getDisplayPath(file.path)
diagnostics.push(`Large ${displayPath} will impact performance (${formatNumber(file.content.length)} chars > ${formatNumber(MAX_MEMORY_CHARACTER_COUNT)})`); diagnostics.push(
}); `Large ${displayPath} will impact performance (${formatNumber(file.content.length)} chars > ${formatNumber(MAX_MEMORY_CHARACTER_COUNT)})`,
return diagnostics; )
})
return diagnostics
} }
export function buildSettingSourcesProperties(): Property[] { export function buildSettingSourcesProperties(): Property[] {
const enabledSources = getEnabledSettingSources(); const enabledSources = getEnabledSettingSources()
// Filter to only sources that actually have settings loaded // Filter to only sources that actually have settings loaded
const sourcesWithSettings = enabledSources.filter(source => { const sourcesWithSettings = enabledSources.filter(source => {
const settings = getSettingsForSource(source); const settings = getSettingsForSource(source)
return settings !== null && Object.keys(settings).length > 0; return settings !== null && Object.keys(settings).length > 0
}); })
// Map internal names to user-friendly names // Map internal names to user-friendly names
// For policySettings, distinguish between remote and local (or skip if neither exists) // For policySettings, distinguish between remote and local (or skip if neither exists)
const sourceNames = sourcesWithSettings.map(source => { const sourceNames = sourcesWithSettings
.map(source => {
if (source === 'policySettings') { if (source === 'policySettings') {
const origin = getPolicySettingsOrigin(); const origin = getPolicySettingsOrigin()
if (origin === null) { if (origin === null) {
return null; // Skip - no policy settings exist return null // Skip - no policy settings exist
} }
switch (origin) { switch (origin) {
case 'remote': case 'remote':
return 'Enterprise managed settings (remote)'; return 'Enterprise managed settings (remote)'
case 'plist': case 'plist':
return 'Enterprise managed settings (plist)'; return 'Enterprise managed settings (plist)'
case 'hklm': case 'hklm':
return 'Enterprise managed settings (HKLM)'; return 'Enterprise managed settings (HKLM)'
case 'file': case 'file': {
{ const { hasBase, hasDropIns } = getManagedFileSettingsPresence()
const {
hasBase,
hasDropIns
} = getManagedFileSettingsPresence();
if (hasBase && hasDropIns) { if (hasBase && hasDropIns) {
return 'Enterprise managed settings (file + drop-ins)'; return 'Enterprise managed settings (file + drop-ins)'
} }
if (hasDropIns) { 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': case 'hkcu':
return 'Enterprise managed settings (HKCU)'; return 'Enterprise managed settings (HKCU)'
} }
} }
return getSettingSourceDisplayNameCapitalized(source); return getSettingSourceDisplayNameCapitalized(source)
}).filter((name): name is string => name !== null); })
return [{ .filter((name): name is string => name !== null)
return [
{
label: 'Setting sources', label: 'Setting sources',
value: sourceNames value: sourceNames,
}]; },
]
} }
export async function buildInstallationDiagnostics(): Promise<Diagnostic[]> { export async function buildInstallationDiagnostics(): Promise<Diagnostic[]> {
const installWarnings = await checkInstall(); const installWarnings = await checkInstall()
return installWarnings.map(warning => warning.message); return installWarnings.map(warning => warning.message)
} }
export async function buildInstallationHealthDiagnostics(): Promise<Diagnostic[]> {
const diagnostic = await getDoctorDiagnostic(); export async function buildInstallationHealthDiagnostics(): Promise<
const items: Diagnostic[] = []; Diagnostic[]
const { > {
errors: validationErrors const diagnostic = await getDoctorDiagnostic()
} = getSettingsWithAllErrors(); const items: Diagnostic[] = []
const { errors: validationErrors } = getSettingsWithAllErrors()
if (validationErrors.length > 0) { if (validationErrors.length > 0) {
const invalidFiles = Array.from(new Set(validationErrors.map(error => error.file))); const invalidFiles = Array.from(
const fileList = invalidFiles.join(', '); new Set(validationErrors.map(error => error.file)),
items.push(`Found invalid settings files: ${fileList}. They will be ignored.`); )
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.) // Add warnings from doctor diagnostic (includes leftover installations, config mismatches, etc.)
diagnostic.warnings.forEach(warning => { diagnostic.warnings.forEach(warning => {
items.push(warning.issue); items.push(warning.issue)
}); })
if (diagnostic.hasUpdatePermissions === false) { 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[] { export function buildAccountProperties(): Property[] {
const accountInfo = getAccountInformation(); const accountInfo = getAccountInformation()
if (!accountInfo) { if (!accountInfo) {
return []; return []
} }
const properties: Property[] = [];
const properties: Property[] = []
if (accountInfo.subscription) { if (accountInfo.subscription) {
properties.push({ properties.push({
label: 'Login method', label: 'Login method',
value: `${accountInfo.subscription} Account` value: `${accountInfo.subscription} Account`,
}); })
} }
if (accountInfo.tokenSource) { if (accountInfo.tokenSource) {
properties.push({ properties.push({
label: 'Auth token', label: 'Auth token',
value: accountInfo.tokenSource value: accountInfo.tokenSource,
}); })
} }
if (accountInfo.apiKeySource) { if (accountInfo.apiKeySource) {
properties.push({ properties.push({
label: 'API key', label: 'API key',
value: accountInfo.apiKeySource value: accountInfo.apiKeySource,
}); })
} }
// Hide sensitive account info in demo mode // Hide sensitive account info in demo mode
if (accountInfo.organization && !process.env.IS_DEMO) { if (accountInfo.organization && !process.env.IS_DEMO) {
properties.push({ properties.push({
label: 'Organization', label: 'Organization',
value: accountInfo.organization value: accountInfo.organization,
}); })
} }
if (accountInfo.email && !process.env.IS_DEMO) { if (accountInfo.email && !process.env.IS_DEMO) {
properties.push({ properties.push({
label: 'Email', label: 'Email',
value: accountInfo.email value: accountInfo.email,
}); })
} }
return properties;
return properties
} }
export function buildAPIProviderProperties(): Property[] { export function buildAPIProviderProperties(): Property[] {
const apiProvider = getAPIProvider(); const apiProvider = getAPIProvider()
const properties: Property[] = [];
const properties: Property[] = []
if (apiProvider !== 'firstParty') { if (apiProvider !== 'firstParty') {
const providerLabel = { const providerLabel = {
bedrock: 'AWS Bedrock', bedrock: 'AWS Bedrock',
vertex: 'Google Vertex AI', vertex: 'Google Vertex AI',
foundry: 'Microsoft Foundry' foundry: 'Microsoft Foundry',
}[apiProvider]; }[apiProvider]
properties.push({ properties.push({
label: 'API provider', label: 'API provider',
value: providerLabel value: providerLabel,
}); })
} }
if (apiProvider === 'firstParty') { if (apiProvider === 'firstParty') {
const anthropicBaseUrl = process.env.ANTHROPIC_BASE_URL; const anthropicBaseUrl = process.env.ANTHROPIC_BASE_URL
if (anthropicBaseUrl) { if (anthropicBaseUrl) {
properties.push({ properties.push({
label: 'Anthropic base URL', label: 'Anthropic base URL',
value: anthropicBaseUrl value: anthropicBaseUrl,
}); })
} }
} else if (apiProvider === 'bedrock') { } else if (apiProvider === 'bedrock') {
const bedrockBaseUrl = process.env.BEDROCK_BASE_URL; const bedrockBaseUrl = process.env.BEDROCK_BASE_URL
if (bedrockBaseUrl) { if (bedrockBaseUrl) {
properties.push({ properties.push({
label: 'Bedrock base URL', label: 'Bedrock base URL',
value: bedrockBaseUrl value: bedrockBaseUrl,
}); })
} }
properties.push({ properties.push({
label: 'AWS region', label: 'AWS region',
value: getAWSRegion() value: getAWSRegion(),
}); })
if (isEnvTruthy(process.env.CLAUDE_CODE_SKIP_BEDROCK_AUTH)) { if (isEnvTruthy(process.env.CLAUDE_CODE_SKIP_BEDROCK_AUTH)) {
properties.push({ properties.push({
value: 'AWS auth skipped' value: 'AWS auth skipped',
}); })
} }
} else if (apiProvider === 'vertex') { } else if (apiProvider === 'vertex') {
const vertexBaseUrl = process.env.VERTEX_BASE_URL; const vertexBaseUrl = process.env.VERTEX_BASE_URL
if (vertexBaseUrl) { if (vertexBaseUrl) {
properties.push({ properties.push({
label: 'Vertex base URL', 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) { if (gcpProject) {
properties.push({ properties.push({
label: 'GCP project', label: 'GCP project',
value: gcpProject value: gcpProject,
}); })
} }
properties.push({ properties.push({
label: 'Default region', label: 'Default region',
value: getDefaultVertexRegion() value: getDefaultVertexRegion(),
}); })
if (isEnvTruthy(process.env.CLAUDE_CODE_SKIP_VERTEX_AUTH)) { if (isEnvTruthy(process.env.CLAUDE_CODE_SKIP_VERTEX_AUTH)) {
properties.push({ properties.push({
value: 'GCP auth skipped' value: 'GCP auth skipped',
}); })
} }
} else if (apiProvider === 'foundry') { } else if (apiProvider === 'foundry') {
const foundryBaseUrl = process.env.ANTHROPIC_FOUNDRY_BASE_URL; const foundryBaseUrl = process.env.ANTHROPIC_FOUNDRY_BASE_URL
if (foundryBaseUrl) { if (foundryBaseUrl) {
properties.push({ properties.push({
label: 'Microsoft Foundry base URL', 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) { if (foundryResource) {
properties.push({ properties.push({
label: 'Microsoft Foundry resource', label: 'Microsoft Foundry resource',
value: foundryResource value: foundryResource,
}); })
} }
if (isEnvTruthy(process.env.CLAUDE_CODE_SKIP_FOUNDRY_AUTH)) { if (isEnvTruthy(process.env.CLAUDE_CODE_SKIP_FOUNDRY_AUTH)) {
properties.push({ properties.push({
value: 'Microsoft Foundry auth skipped' value: 'Microsoft Foundry auth skipped',
}); })
} }
} }
const proxyUrl = getProxyUrl();
const proxyUrl = getProxyUrl()
if (proxyUrl) { if (proxyUrl) {
properties.push({ properties.push({
label: 'Proxy', label: 'Proxy',
value: proxyUrl value: proxyUrl,
}); })
} }
const mtlsConfig = getMTLSConfig();
const mtlsConfig = getMTLSConfig()
if (process.env.NODE_EXTRA_CA_CERTS) { if (process.env.NODE_EXTRA_CA_CERTS) {
properties.push({ properties.push({
label: 'Additional CA cert(s)', label: 'Additional CA cert(s)',
value: process.env.NODE_EXTRA_CA_CERTS value: process.env.NODE_EXTRA_CA_CERTS,
}); })
} }
if (mtlsConfig) { if (mtlsConfig) {
if (mtlsConfig.cert && process.env.CLAUDE_CODE_CLIENT_CERT) { if (mtlsConfig.cert && process.env.CLAUDE_CODE_CLIENT_CERT) {
properties.push({ properties.push({
label: 'mTLS client cert', 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) { if (mtlsConfig.key && process.env.CLAUDE_CODE_CLIENT_KEY) {
properties.push({ properties.push({
label: 'mTLS client key', 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 { export function getModelDisplayLabel(mainLoopModel: string | null): string {
let modelLabel = modelDisplayString(mainLoopModel); let modelLabel = modelDisplayString(mainLoopModel)
if (mainLoopModel === null && isClaudeAISubscriber()) { if (mainLoopModel === null && isClaudeAISubscriber()) {
const description = getClaudeAiUserDefaultModelDescription(); const description = getClaudeAiUserDefaultModelDescription()
modelLabel = `${chalk.bold('Default')} ${description}`;
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 // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
import { Box, Text } from '../ink.js'; import { Box, Text } from '../ink.js'
import * as React from 'react'; import * as React from 'react'
import { getLargeMemoryFiles, MAX_MEMORY_CHARACTER_COUNT, type MemoryFileInfo } from './claudemd.js'; import {
import figures from 'figures'; getLargeMemoryFiles,
import { getCwd } from './cwd.js'; MAX_MEMORY_CHARACTER_COUNT,
import { relative } from 'path'; type MemoryFileInfo,
import { formatNumber } from './format.js'; } from './claudemd.js'
import type { getGlobalConfig } from './config.js'; import figures from 'figures'
import { getAnthropicApiKeyWithSource, getApiKeyFromConfigOrMacOSKeychain, getAuthTokenSource, isClaudeAISubscriber } from './auth.js'; import { getCwd } from './cwd.js'
import type { AgentDefinitionsResult } from '../tools/AgentTool/loadAgentsDir.js'; import { relative } from 'path'
import { getAgentDescriptionsTotalTokens, AGENT_DESCRIPTIONS_THRESHOLD } from './statusNoticeHelpers.js'; import { formatNumber } from './format.js'
import { isSupportedJetBrainsTerminal, toIDEDisplayName, getTerminalIdeType } from './ide.js'; import type { getGlobalConfig } from './config.js'
import { isJetBrainsPluginInstalledCachedSync } from './jetbrains.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 // Types
export type StatusNoticeType = 'warning' | 'info'; export type StatusNoticeType = 'warning' | 'info'
export type StatusNoticeContext = { export type StatusNoticeContext = {
config: ReturnType<typeof getGlobalConfig>; config: ReturnType<typeof getGlobalConfig>
agentDefinitions?: AgentDefinitionsResult; agentDefinitions?: AgentDefinitionsResult
memoryFiles: MemoryFileInfo[]; memoryFiles: MemoryFileInfo[]
}; }
export type StatusNoticeDefinition = { export type StatusNoticeDefinition = {
id: string; id: string
type: StatusNoticeType; type: StatusNoticeType
isActive: (context: StatusNoticeContext) => boolean; isActive: (context: StatusNoticeContext) => boolean
render: (context: StatusNoticeContext) => React.ReactNode; render: (context: StatusNoticeContext) => React.ReactNode
}; }
// Individual notice definitions // Individual notice definitions
const largeMemoryFilesNotice: StatusNoticeDefinition = { const largeMemoryFilesNotice: StatusNoticeDefinition = {
@@ -33,11 +51,16 @@ const largeMemoryFilesNotice: StatusNoticeDefinition = {
type: 'warning', type: 'warning',
isActive: ctx => getLargeMemoryFiles(ctx.memoryFiles).length > 0, isActive: ctx => getLargeMemoryFiles(ctx.memoryFiles).length > 0,
render: ctx => { render: ctx => {
const largeMemoryFiles = getLargeMemoryFiles(ctx.memoryFiles); const largeMemoryFiles = getLargeMemoryFiles(ctx.memoryFiles)
return <> return (
<>
{largeMemoryFiles.map(file => { {largeMemoryFiles.map(file => {
const displayPath = file.path.startsWith(getCwd()) ? relative(getCwd(), file.path) : file.path; const displayPath = file.path.startsWith(getCwd())
return <Box key={file.path} flexDirection="row"> ? relative(getCwd(), file.path)
: file.path
return (
<Box key={file.path} flexDirection="row">
<Text color="warning">{figures.warning}</Text> <Text color="warning">{figures.warning}</Text>
<Text color="warning"> <Text color="warning">
Large <Text bold>{displayPath}</Text> will impact performance ( Large <Text bold>{displayPath}</Text> will impact performance (
@@ -45,76 +68,92 @@ const largeMemoryFilesNotice: StatusNoticeDefinition = {
{formatNumber(MAX_MEMORY_CHARACTER_COUNT)}) {formatNumber(MAX_MEMORY_CHARACTER_COUNT)})
<Text dimColor> · /memory to edit</Text> <Text dimColor> · /memory to edit</Text>
</Text> </Text>
</Box>; </Box>
)
})} })}
</>; </>
)
},
} }
};
const claudeAiSubscriberExternalTokenNotice: StatusNoticeDefinition = { const claudeAiSubscriberExternalTokenNotice: StatusNoticeDefinition = {
id: 'claude-ai-external-token', id: 'claude-ai-external-token',
type: 'warning', type: 'warning',
isActive: () => { isActive: () => {
const authTokenInfo = getAuthTokenSource(); const authTokenInfo = getAuthTokenSource()
return isClaudeAISubscriber() && (authTokenInfo.source === 'ANTHROPIC_AUTH_TOKEN' || authTokenInfo.source === 'apiKeyHelper'); return (
isClaudeAISubscriber() &&
(authTokenInfo.source === 'ANTHROPIC_AUTH_TOKEN' ||
authTokenInfo.source === 'apiKeyHelper')
)
}, },
render: () => { render: () => {
const authTokenInfo = getAuthTokenSource(); const authTokenInfo = getAuthTokenSource()
return <Box flexDirection="row" marginTop={1}> return (
<Box flexDirection="row" marginTop={1}>
<Text color="warning">{figures.warning}</Text> <Text color="warning">{figures.warning}</Text>
<Text color="warning"> <Text color="warning">
Auth conflict: Using {authTokenInfo.source} instead of Claude account Auth conflict: Using {authTokenInfo.source} instead of Claude account
subscription token. Either unset {authTokenInfo.source}, or run subscription token. Either unset {authTokenInfo.source}, or run
`claude /logout`. `claude /logout`.
</Text> </Text>
</Box>; </Box>
)
},
} }
};
const apiKeyConflictNotice: StatusNoticeDefinition = { const apiKeyConflictNotice: StatusNoticeDefinition = {
id: 'api-key-conflict', id: 'api-key-conflict',
type: 'warning', type: 'warning',
isActive: () => { isActive: () => {
const { const { source: apiKeySource } = getAnthropicApiKeyWithSource({
source: apiKeySource skipRetrievingKeyFromApiKeyHelper: true,
} = getAnthropicApiKeyWithSource({ })
skipRetrievingKeyFromApiKeyHelper: true return (
}); !!getApiKeyFromConfigOrMacOSKeychain() &&
return !!getApiKeyFromConfigOrMacOSKeychain() && (apiKeySource === 'ANTHROPIC_API_KEY' || apiKeySource === 'apiKeyHelper'); (apiKeySource === 'ANTHROPIC_API_KEY' || apiKeySource === 'apiKeyHelper')
)
}, },
render: () => { render: () => {
const { const { source: apiKeySource } = getAnthropicApiKeyWithSource({
source: apiKeySource skipRetrievingKeyFromApiKeyHelper: true,
} = getAnthropicApiKeyWithSource({ })
skipRetrievingKeyFromApiKeyHelper: true return (
}); <Box flexDirection="row" marginTop={1}>
return <Box flexDirection="row" marginTop={1}>
<Text color="warning">{figures.warning}</Text> <Text color="warning">{figures.warning}</Text>
<Text color="warning"> <Text color="warning">
Auth conflict: Using {apiKeySource} instead of Anthropic Console key. Auth conflict: Using {apiKeySource} instead of Anthropic Console key.
Either unset {apiKeySource}, or run `claude /logout`. Either unset {apiKeySource}, or run `claude /logout`.
</Text> </Text>
</Box>; </Box>
)
},
} }
};
const bothAuthMethodsNotice: StatusNoticeDefinition = { const bothAuthMethodsNotice: StatusNoticeDefinition = {
id: 'both-auth-methods', id: 'both-auth-methods',
type: 'warning', type: 'warning',
isActive: () => { isActive: () => {
const { const { source: apiKeySource } = getAnthropicApiKeyWithSource({
source: apiKeySource skipRetrievingKeyFromApiKeyHelper: true,
} = getAnthropicApiKeyWithSource({ })
skipRetrievingKeyFromApiKeyHelper: true const authTokenInfo = getAuthTokenSource()
}); return (
const authTokenInfo = getAuthTokenSource(); apiKeySource !== 'none' &&
return apiKeySource !== 'none' && authTokenInfo.source !== 'none' && !(apiKeySource === 'apiKeyHelper' && authTokenInfo.source === 'apiKeyHelper'); authTokenInfo.source !== 'none' &&
!(
apiKeySource === 'apiKeyHelper' &&
authTokenInfo.source === 'apiKeyHelper'
)
)
}, },
render: () => { render: () => {
const { const { source: apiKeySource } = getAnthropicApiKeyWithSource({
source: apiKeySource skipRetrievingKeyFromApiKeyHelper: true,
} = getAnthropicApiKeyWithSource({ })
skipRetrievingKeyFromApiKeyHelper: true const authTokenInfo = getAuthTokenSource()
}); return (
const authTokenInfo = getAuthTokenSource(); <Box flexDirection="column" marginTop={1}>
return <Box flexDirection="column" marginTop={1}>
<Box flexDirection="row"> <Box flexDirection="row">
<Text color="warning">{figures.warning}</Text> <Text color="warning">{figures.warning}</Text>
<Text color="warning"> <Text color="warning">
@@ -125,28 +164,43 @@ const bothAuthMethodsNotice: StatusNoticeDefinition = {
<Box flexDirection="column" marginLeft={3}> <Box flexDirection="column" marginLeft={3}>
<Text color="warning"> <Text color="warning">
· Trying to use{' '} · 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>
<Text color="warning"> <Text color="warning">
· Trying to use {apiKeySource}?{' '} · 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> </Text>
</Box> </Box>
</Box>; </Box>
)
},
} }
};
const largeAgentDescriptionsNotice: StatusNoticeDefinition = { const largeAgentDescriptionsNotice: StatusNoticeDefinition = {
id: 'large-agent-descriptions', id: 'large-agent-descriptions',
type: 'warning', type: 'warning',
isActive: context => { isActive: context => {
const totalTokens = getAgentDescriptionsTotalTokens(context.agentDefinitions); const totalTokens = getAgentDescriptionsTotalTokens(
return totalTokens > AGENT_DESCRIPTIONS_THRESHOLD; context.agentDefinitions,
)
return totalTokens > AGENT_DESCRIPTIONS_THRESHOLD
}, },
render: context => { render: context => {
const totalTokens = getAgentDescriptionsTotalTokens(context.agentDefinitions); const totalTokens = getAgentDescriptionsTotalTokens(
return <Box flexDirection="row"> context.agentDefinitions,
)
return (
<Box flexDirection="row">
<Text color="warning">{figures.warning}</Text> <Text color="warning">{figures.warning}</Text>
<Text color="warning"> <Text color="warning">
Large cumulative agent descriptions will impact performance (~ Large cumulative agent descriptions will impact performance (~
@@ -154,44 +208,58 @@ const largeAgentDescriptionsNotice: StatusNoticeDefinition = {
{formatNumber(AGENT_DESCRIPTIONS_THRESHOLD)}) {formatNumber(AGENT_DESCRIPTIONS_THRESHOLD)})
<Text dimColor> · /agents to manage</Text> <Text dimColor> · /agents to manage</Text>
</Text> </Text>
</Box>; </Box>
)
},
} }
};
const jetbrainsPluginNotice: StatusNoticeDefinition = { const jetbrainsPluginNotice: StatusNoticeDefinition = {
id: 'jetbrains-plugin-install', id: 'jetbrains-plugin-install',
type: 'info', type: 'info',
isActive: context => { isActive: context => {
// Only show if running in JetBrains built-in terminal // Only show if running in JetBrains built-in terminal
if (!isSupportedJetBrainsTerminal()) { if (!isSupportedJetBrainsTerminal()) {
return false; return false
} }
// Don't show if auto-install is disabled // Don't show if auto-install is disabled
const shouldAutoInstall = context.config.autoInstallIdeExtension ?? true; const shouldAutoInstall = context.config.autoInstallIdeExtension ?? true
if (!shouldAutoInstall) { if (!shouldAutoInstall) {
return false; return false
} }
// Check if plugin is already installed (cached to avoid repeated filesystem checks) // Check if plugin is already installed (cached to avoid repeated filesystem checks)
const ideType = getTerminalIdeType(); const ideType = getTerminalIdeType()
return ideType !== null && !isJetBrainsPluginInstalledCachedSync(ideType); return ideType !== null && !isJetBrainsPluginInstalledCachedSync(ideType)
}, },
render: () => { render: () => {
const ideType = getTerminalIdeType(); const ideType = getTerminalIdeType()
const ideName = toIDEDisplayName(ideType); const ideName = toIDEDisplayName(ideType)
return <Box flexDirection="row" gap={1} marginLeft={1}> return (
<Box flexDirection="row" gap={1} marginLeft={1}>
<Text color="ide">{figures.arrowUp}</Text> <Text color="ide">{figures.arrowUp}</Text>
<Text> <Text>
Install the <Text color="ide">{ideName}</Text> plugin from the Install the <Text color="ide">{ideName}</Text> plugin from the
JetBrains Marketplace:{' '} JetBrains Marketplace:{' '}
<Text bold>https://docs.claude.com/s/claude-code-jetbrains</Text> <Text bold>https://docs.claude.com/s/claude-code-jetbrains</Text>
</Text> </Text>
</Box>; </Box>
)
},
} }
};
// All notice definitions // 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 // Helper functions for external use
export function getActiveNotices(context: StatusNoticeContext): StatusNoticeDefinition[] { export function getActiveNotices(
return statusNoticeDefinitions.filter(notice => notice.isActive(context)); 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 React, { useCallback, useEffect, useState } from 'react'; import {
import { type OptionWithDescription, Select } from '../../components/CustomSelect/index.js'; type OptionWithDescription,
import { Pane } from '../../components/design-system/Pane.js'; Select,
import { Spinner } from '../../components/Spinner.js'; } from '../../components/CustomSelect/index.js'
import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.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 // eslint-disable-next-line custom-rules/prefer-use-keybindings -- enter to proceed through setup steps
import { Box, Text, useInput } from '../../ink.js'; import { Box, Text, useInput } from '../../ink.js'
import { useKeybinding } from '../../keybindings/useKeybinding.js'; import { useKeybinding } from '../../keybindings/useKeybinding.js'
import { detectPythonPackageManager, getPythonApiInstructions, installIt2, markIt2SetupComplete, type PythonPackageManager, setPreferTmuxOverIterm2, verifyIt2Setup } from './backends/it2Setup.js'; import {
type SetupStep = 'initial' | 'installing' | 'install-failed' | 'verify-api' | 'api-instructions' | 'verifying' | 'success' | 'failed'; 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 = { type Props = {
onDone: (result: 'installed' | 'use-tmux' | 'cancelled') => void; onDone: (result: 'installed' | 'use-tmux' | 'cancelled') => void
tmuxAvailable: boolean; tmuxAvailable: boolean
}; }
export function It2SetupPrompt(t0) {
const $ = _c(44); export function It2SetupPrompt({
const {
onDone, onDone,
tmuxAvailable tmuxAvailable,
} = t0; }: Props): React.ReactNode {
const [step, setStep] = useState("initial"); const [step, setStep] = useState<SetupStep>('initial')
const [packageManager, setPackageManager] = useState(null); const [packageManager, setPackageManager] =
const [error, setError] = useState(null); useState<PythonPackageManager | null>(null)
const exitState = useExitOnCtrlCDWithKeybindings(); const [error, setError] = useState<string | null>(null)
let t1; const exitState = useExitOnCtrlCDWithKeybindings()
let t2;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) { // Detect package manager on mount
t1 = () => { useEffect(() => {
detectPythonPackageManager().then(pm => { void detectPythonPackageManager().then(pm => {
setPackageManager(pm); setPackageManager(pm)
}); })
}; }, [])
t2 = [];
$[0] = t1; const handleCancel = useCallback(() => {
$[1] = t2; onDone('cancelled')
} else { }, [onDone])
t1 = $[0];
t2 = $[1]; useKeybinding('confirm:no', handleCancel, {
} context: 'Confirmation',
useEffect(t1, t2); isActive: step !== 'installing' && step !== 'verifying',
let t3; })
if ($[2] !== onDone) {
t3 = () => { // Handle keyboard input for verification step
onDone("cancelled"); useInput((_input, key) => {
}; if (step === 'api-instructions' && key.return) {
$[2] = onDone; setStep('verifying')
$[3] = t3; void verifyIt2Setup().then(result => {
} 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) { if (result.success) {
markIt2SetupComplete(); markIt2SetupComplete()
setStep("success"); setStep('success')
setTimeout(onDone, 1500, "installed" as const); setTimeout(onDone, 1500, 'installed' as const)
} else { } else {
setError(result.error || "Verification failed"); setError(result.error || 'Verification failed')
setStep("failed"); setStep('failed')
} }
}); })
} }
}; })
$[6] = onDone;
$[7] = step; // Handle installation
$[8] = t6; async function handleInstall(): Promise<void> {
} else {
t6 = $[8];
}
useInput(t6);
let t7;
if ($[9] !== packageManager) {
t7 = async function handleInstall() {
if (!packageManager) { if (!packageManager) {
setError("No Python package manager found (uvx, pipx, or pip)"); setError('No Python package manager found (uvx, pipx, or pip)')
setStep("failed"); setStep('failed')
return; return
} }
setStep("installing");
const result_0 = await installIt2(packageManager); setStep('installing')
if (result_0.success) { const result = await installIt2(packageManager)
setStep("api-instructions");
if (result.success) {
// Show Python API instructions
setStep('api-instructions')
} else { } else {
setError(result_0.error || "Installation failed"); setError(result.error || 'Installation failed')
setStep("install-failed"); setStep('install-failed')
} }
};
$[9] = packageManager;
$[10] = t7;
} else {
t7 = $[10];
} }
const handleInstall = t7;
let t8; // Handle using tmux instead
if ($[11] !== onDone) { function handleUseTmux(): void {
t8 = function handleUseTmux() { setPreferTmuxOverIterm2(true)
setPreferTmuxOverIterm2(true); onDone('use-tmux')
onDone("use-tmux");
};
$[11] = onDone;
$[12] = t8;
} else {
t8 = $[12];
} }
const handleUseTmux = t8;
let T0; // Render based on current step
let T1; const renderContent = (): React.ReactNode => {
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) { switch (step) {
case "initial": case 'initial':
{ return renderInitialPrompt()
return renderInitialPrompt(); case 'installing':
} return renderInstalling()
case "installing": case 'install-failed':
{ return renderInstallFailed()
return renderInstalling(); case 'api-instructions':
} return renderApiInstructions()
case "install-failed": case 'verifying':
{ return renderVerifying()
return renderInstallFailed(); case 'success':
} return renderSuccess()
case "api-instructions": case 'failed':
{ return renderFailed()
return renderApiInstructions();
}
case "verifying":
{
return renderVerifying();
}
case "success":
{
return renderSuccess();
}
case "failed":
{
return renderFailed();
}
default: default:
return null
}
}
function renderInitialPrompt(): React.ReactNode {
const options: OptionWithDescription<string>[] = [
{ {
return null; label: 'Install it2 now',
} value: 'install',
} description: packageManager
}; ? `Uses ${packageManager} to install the it2 CLI tool`
function renderInitialPrompt() { : 'Requires Python (uvx, pipx, or pip)',
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) { if (tmuxAvailable) {
options.push({ options.push({
label: "Use tmux instead", label: 'Use tmux instead',
value: "tmux", value: 'tmux',
description: "Opens teammates in a separate tmux session" description: 'Opens teammates in a separate tmux session',
}); })
} }
options.push({ options.push({
label: "Cancel", label: 'Cancel',
value: "cancel", value: 'cancel',
description: "Skip teammate spawning for now" 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) { return (
case "install": <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>
)
}
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>[] = [
{ {
handleInstall(); label: 'Try again',
break bb61; value: 'retry',
} description: 'Retry the installation',
case "tmux": },
{ ]
handleUseTmux();
break bb61;
}
case "cancel":
{
onDone("cancelled");
}
}
}} onCancel={() => onDone("cancelled")} /></Box></Box>;
}
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>;
}
function renderInstallFailed() {
const options_0 = [{
label: "Try again",
value: "retry",
description: "Retry the installation"
}];
if (tmuxAvailable) { if (tmuxAvailable) {
options_0.push({ options.push({
label: "Use tmux instead", label: 'Use tmux instead',
value: "tmux", value: 'tmux',
description: "Falls back to tmux for teammate panes" description: 'Falls back to tmux for teammate panes',
}); })
} }
options_0.push({
label: "Cancel", options.push({
value: "cancel", label: 'Cancel',
description: "Skip teammate spawning for now" 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": 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>[] = [
{ {
handleInstall(); label: 'Try again',
break bb89; value: 'retry',
} description: 'Verify the connection again',
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) { if (tmuxAvailable) {
options_1.push({ options.push({
label: "Use tmux instead", label: 'Use tmux instead',
value: "tmux", value: 'tmux',
description: "Falls back to tmux for teammate panes" description: 'Falls back to tmux for teammate panes',
}); })
} }
options_1.push({
label: "Cancel", options.push({
value: "cancel", label: 'Cancel',
description: "Skip teammate spawning for now" 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": return (
{ <Box flexDirection="column" gap={1}>
setStep("verifying"); <Text color="error">Verification failed</Text>
verifyIt2Setup().then(result_1 => { {error && <Text dimColor>{error}</Text>}
if (result_1.success) { <Text>Make sure:</Text>
markIt2SetupComplete(); <Box flexDirection="column" paddingLeft={2}>
setStep("success"); <Text>· Python API is enabled in iTerm2 preferences</Text>
setTimeout(onDone, 1500, "installed" as const); <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 { } else {
setError(result_1.error || "Verification failed"); setError(result.error || 'Verification failed')
setStep("failed"); setStep('failed')
} }
}); })
break bb115; break
case 'tmux':
handleUseTmux()
break
case 'cancel':
onDone('cancelled')
break
} }
case "tmux": }}
{ onCancel={() => onDone('cancelled')}
handleUseTmux(); />
break bb115; </Box>
</Box>
)
} }
case "cancel":
{ return (
onDone("cancelled"); <Pane color="permission">
} <Box flexDirection="column" gap={1} paddingBottom={1}>
} <Text bold color="permission">
}} onCancel={() => onDone("cancelled")} /></Box></Box>; iTerm2 Split Pane Setup
} </Text>
T1 = Pane; {renderContent()}
t14 = "permission"; {step !== 'installing' &&
T0 = Box; step !== 'verifying' &&
t9 = "column"; step !== 'success' && (
t10 = 1; <Text dimColor italic>
t11 = 1; {exitState.pending ? (
if ($[28] === Symbol.for("react.memo_cache_sentinel")) { <>Press {exitState.keyName} again to exit</>
t12 = <Text bold={true} color="permission">iTerm2 Split Pane Setup</Text>; ) : (
$[28] = t12; <>Esc to cancel</>
} else { )}
t12 = $[28]; </Text>
} )}
t13 = renderContent(); </Box>
$[13] = error; </Pane>
$[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];
}
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];
}
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];
}
return t17;
}
function _temp(line, i) {
return <Text key={i}>{line}</Text>;
} }

File diff suppressed because it is too large Load Diff