mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
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:
@@ -6,62 +6,91 @@
|
||||
* Part of the main.tsx React/JSX extraction effort. See sibling PRs
|
||||
* perf/extract-interactive-helpers and perf/launch-repl.
|
||||
*/
|
||||
import React from 'react';
|
||||
import type { AssistantSession } from './assistant/sessionDiscovery.js';
|
||||
import type { StatsStore } from './context/stats.js';
|
||||
import type { Root } from './ink.js';
|
||||
import { renderAndRun, showSetupDialog } from './interactiveHelpers.js';
|
||||
import { KeybindingSetup } from './keybindings/KeybindingProviderSetup.js';
|
||||
import type { AppState } from './state/AppStateStore.js';
|
||||
import type { AgentMemoryScope } from './tools/AgentTool/agentMemory.js';
|
||||
import type { TeleportRemoteResponse } from './utils/conversationRecovery.js';
|
||||
import type { FpsMetrics } from './utils/fpsTracker.js';
|
||||
import type { ValidationError } from './utils/settings/validation.js';
|
||||
import React from 'react'
|
||||
import type { AssistantSession } from './assistant/sessionDiscovery.js'
|
||||
import type { StatsStore } from './context/stats.js'
|
||||
import type { Root } from './ink.js'
|
||||
import { renderAndRun, showSetupDialog } from './interactiveHelpers.js'
|
||||
import { KeybindingSetup } from './keybindings/KeybindingProviderSetup.js'
|
||||
import type { AppState } from './state/AppStateStore.js'
|
||||
import type { AgentMemoryScope } from './tools/AgentTool/agentMemory.js'
|
||||
import type { TeleportRemoteResponse } from './utils/conversationRecovery.js'
|
||||
import type { FpsMetrics } from './utils/fpsTracker.js'
|
||||
import type { ValidationError } from './utils/settings/validation.js'
|
||||
|
||||
// Type-only access to ResumeConversation's Props via the module type.
|
||||
// No runtime cost - erased at compile time.
|
||||
type ResumeConversationProps = React.ComponentProps<typeof import('./screens/ResumeConversation.js').ResumeConversation>;
|
||||
type ResumeConversationProps = React.ComponentProps<
|
||||
typeof import('./screens/ResumeConversation.js').ResumeConversation
|
||||
>
|
||||
|
||||
/**
|
||||
* Site ~3173: SnapshotUpdateDialog (agent memory snapshot update prompt).
|
||||
* Original callback wiring: onComplete={done}, onCancel={() => done('keep')}.
|
||||
*/
|
||||
export async function launchSnapshotUpdateDialog(root: Root, props: {
|
||||
agentType: string;
|
||||
scope: AgentMemoryScope;
|
||||
snapshotTimestamp: string;
|
||||
}): Promise<'merge' | 'keep' | 'replace'> {
|
||||
const {
|
||||
SnapshotUpdateDialog
|
||||
} = await import('./components/agents/SnapshotUpdateDialog.js');
|
||||
return showSetupDialog<'merge' | 'keep' | 'replace'>(root, done => <SnapshotUpdateDialog agentType={props.agentType} scope={props.scope} snapshotTimestamp={props.snapshotTimestamp} onComplete={done} onCancel={() => done('keep')} />);
|
||||
export async function launchSnapshotUpdateDialog(
|
||||
root: Root,
|
||||
props: {
|
||||
agentType: string
|
||||
scope: AgentMemoryScope
|
||||
snapshotTimestamp: string
|
||||
},
|
||||
): Promise<'merge' | 'keep' | 'replace'> {
|
||||
const { SnapshotUpdateDialog } = await import(
|
||||
'./components/agents/SnapshotUpdateDialog.js'
|
||||
)
|
||||
return showSetupDialog<'merge' | 'keep' | 'replace'>(root, done => (
|
||||
<SnapshotUpdateDialog
|
||||
agentType={props.agentType}
|
||||
scope={props.scope}
|
||||
snapshotTimestamp={props.snapshotTimestamp}
|
||||
onComplete={done}
|
||||
onCancel={() => done('keep')}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* Site ~3250: InvalidSettingsDialog (settings validation errors).
|
||||
* Original callback wiring: onContinue={done}, onExit passed through from caller.
|
||||
*/
|
||||
export async function launchInvalidSettingsDialog(root: Root, props: {
|
||||
settingsErrors: ValidationError[];
|
||||
onExit: () => void;
|
||||
}): Promise<void> {
|
||||
const {
|
||||
InvalidSettingsDialog
|
||||
} = await import('./components/InvalidSettingsDialog.js');
|
||||
return showSetupDialog(root, done => <InvalidSettingsDialog settingsErrors={props.settingsErrors} onContinue={done} onExit={props.onExit} />);
|
||||
export async function launchInvalidSettingsDialog(
|
||||
root: Root,
|
||||
props: {
|
||||
settingsErrors: ValidationError[]
|
||||
onExit: () => void
|
||||
},
|
||||
): Promise<void> {
|
||||
const { InvalidSettingsDialog } = await import(
|
||||
'./components/InvalidSettingsDialog.js'
|
||||
)
|
||||
return showSetupDialog(root, done => (
|
||||
<InvalidSettingsDialog
|
||||
settingsErrors={props.settingsErrors}
|
||||
onContinue={done}
|
||||
onExit={props.onExit}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* Site ~4229: AssistantSessionChooser (pick a bridge session to attach to).
|
||||
* Original callback wiring: onSelect={id => done(id)}, onCancel={() => done(null)}.
|
||||
*/
|
||||
export async function launchAssistantSessionChooser(root: Root, props: {
|
||||
sessions: AssistantSession[];
|
||||
}): Promise<string | null> {
|
||||
const {
|
||||
AssistantSessionChooser
|
||||
} = await import('./assistant/AssistantSessionChooser.js');
|
||||
return showSetupDialog<string | null>(root, done => <AssistantSessionChooser sessions={props.sessions} onSelect={id => done(id)} onCancel={() => done(null)} />);
|
||||
export async function launchAssistantSessionChooser(
|
||||
root: Root,
|
||||
props: { sessions: AssistantSession[] },
|
||||
): Promise<string | null> {
|
||||
const { AssistantSessionChooser } = await import(
|
||||
'./assistant/AssistantSessionChooser.js'
|
||||
)
|
||||
return showSetupDialog<string | null>(root, done => (
|
||||
<AssistantSessionChooser
|
||||
sessions={props.sessions}
|
||||
onSelect={id => done(id)}
|
||||
onCancel={() => done(null)}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,43 +99,71 @@ export async function launchAssistantSessionChooser(root: Root, props: {
|
||||
* success, null on cancel. Rejects on install failure so the caller can
|
||||
* distinguish errors from user cancellation.
|
||||
*/
|
||||
export async function launchAssistantInstallWizard(root: Root): Promise<string | null> {
|
||||
const {
|
||||
NewInstallWizard,
|
||||
computeDefaultInstallDir
|
||||
} = await import('./commands/assistant/assistant.js');
|
||||
const defaultDir = await computeDefaultInstallDir();
|
||||
let rejectWithError: (reason: Error) => void;
|
||||
export async function launchAssistantInstallWizard(
|
||||
root: Root,
|
||||
): Promise<string | null> {
|
||||
const { NewInstallWizard, computeDefaultInstallDir } = await import(
|
||||
'./commands/assistant/assistant.js'
|
||||
)
|
||||
const defaultDir = await computeDefaultInstallDir()
|
||||
let rejectWithError: (reason: Error) => void
|
||||
const errorPromise = new Promise<never>((_, reject) => {
|
||||
rejectWithError = reject;
|
||||
});
|
||||
const resultPromise = showSetupDialog<string | null>(root, done => <NewInstallWizard defaultDir={defaultDir} onInstalled={dir => done(dir)} onCancel={() => done(null)} onError={message => rejectWithError(new Error(`Installation failed: ${message}`))} />);
|
||||
return Promise.race([resultPromise, errorPromise]);
|
||||
rejectWithError = reject
|
||||
})
|
||||
const resultPromise = showSetupDialog<string | null>(root, done => (
|
||||
<NewInstallWizard
|
||||
defaultDir={defaultDir}
|
||||
onInstalled={dir => done(dir)}
|
||||
onCancel={() => done(null)}
|
||||
onError={message =>
|
||||
rejectWithError(new Error(`Installation failed: ${message}`))
|
||||
}
|
||||
/>
|
||||
))
|
||||
return Promise.race([resultPromise, errorPromise])
|
||||
}
|
||||
|
||||
/**
|
||||
* Site ~4549: TeleportResumeWrapper (interactive teleport session picker).
|
||||
* Original callback wiring: onComplete={done}, onCancel={() => done(null)}, source="cliArg".
|
||||
*/
|
||||
export async function launchTeleportResumeWrapper(root: Root): Promise<TeleportRemoteResponse | null> {
|
||||
const {
|
||||
TeleportResumeWrapper
|
||||
} = await import('./components/TeleportResumeWrapper.js');
|
||||
return showSetupDialog<TeleportRemoteResponse | null>(root, done => <TeleportResumeWrapper onComplete={done} onCancel={() => done(null)} source="cliArg" />);
|
||||
export async function launchTeleportResumeWrapper(
|
||||
root: Root,
|
||||
): Promise<TeleportRemoteResponse | null> {
|
||||
const { TeleportResumeWrapper } = await import(
|
||||
'./components/TeleportResumeWrapper.js'
|
||||
)
|
||||
return showSetupDialog<TeleportRemoteResponse | null>(root, done => (
|
||||
<TeleportResumeWrapper
|
||||
onComplete={done}
|
||||
onCancel={() => done(null)}
|
||||
source="cliArg"
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* Site ~4597: TeleportRepoMismatchDialog (pick a local checkout of the target repo).
|
||||
* Original callback wiring: onSelectPath={done}, onCancel={() => done(null)}.
|
||||
*/
|
||||
export async function launchTeleportRepoMismatchDialog(root: Root, props: {
|
||||
targetRepo: string;
|
||||
initialPaths: string[];
|
||||
}): Promise<string | null> {
|
||||
const {
|
||||
TeleportRepoMismatchDialog
|
||||
} = await import('./components/TeleportRepoMismatchDialog.js');
|
||||
return showSetupDialog<string | null>(root, done => <TeleportRepoMismatchDialog targetRepo={props.targetRepo} initialPaths={props.initialPaths} onSelectPath={done} onCancel={() => done(null)} />);
|
||||
export async function launchTeleportRepoMismatchDialog(
|
||||
root: Root,
|
||||
props: {
|
||||
targetRepo: string
|
||||
initialPaths: string[]
|
||||
},
|
||||
): Promise<string | null> {
|
||||
const { TeleportRepoMismatchDialog } = await import(
|
||||
'./components/TeleportRepoMismatchDialog.js'
|
||||
)
|
||||
return showSetupDialog<string | null>(root, done => (
|
||||
<TeleportRepoMismatchDialog
|
||||
targetRepo={props.targetRepo}
|
||||
initialPaths={props.initialPaths}
|
||||
onSelectPath={done}
|
||||
onCancel={() => done(null)}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,19 +171,31 @@ export async function launchTeleportRepoMismatchDialog(root: Root, props: {
|
||||
* Uses renderAndRun, NOT showSetupDialog. Wraps in <App><KeybindingSetup>.
|
||||
* Preserves original Promise.all parallelism between getWorktreePaths and imports.
|
||||
*/
|
||||
export async function launchResumeChooser(root: Root, appProps: {
|
||||
getFpsMetrics: () => FpsMetrics | undefined;
|
||||
stats: StatsStore;
|
||||
initialState: AppState;
|
||||
}, worktreePathsPromise: Promise<string[]>, resumeProps: Omit<ResumeConversationProps, 'worktreePaths'>): Promise<void> {
|
||||
const [worktreePaths, {
|
||||
ResumeConversation
|
||||
}, {
|
||||
App
|
||||
}] = await Promise.all([worktreePathsPromise, import('./screens/ResumeConversation.js'), import('./components/App.js')]);
|
||||
await renderAndRun(root, <App getFpsMetrics={appProps.getFpsMetrics} stats={appProps.stats} initialState={appProps.initialState}>
|
||||
export async function launchResumeChooser(
|
||||
root: Root,
|
||||
appProps: {
|
||||
getFpsMetrics: () => FpsMetrics | undefined
|
||||
stats: StatsStore
|
||||
initialState: AppState
|
||||
},
|
||||
worktreePathsPromise: Promise<string[]>,
|
||||
resumeProps: Omit<ResumeConversationProps, 'worktreePaths'>,
|
||||
): Promise<void> {
|
||||
const [worktreePaths, { ResumeConversation }, { App }] = await Promise.all([
|
||||
worktreePathsPromise,
|
||||
import('./screens/ResumeConversation.js'),
|
||||
import('./components/App.js'),
|
||||
])
|
||||
await renderAndRun(
|
||||
root,
|
||||
<App
|
||||
getFpsMetrics={appProps.getFpsMetrics}
|
||||
stats={appProps.stats}
|
||||
initialState={appProps.initialState}
|
||||
>
|
||||
<KeybindingSetup>
|
||||
<ResumeConversation {...resumeProps} worktreePaths={worktreePaths} />
|
||||
</KeybindingSetup>
|
||||
</App>);
|
||||
</App>,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
#!/usr/bin/env bun
|
||||
import { feature } from 'bun:bundle';
|
||||
import { feature } from 'bun:bundle'
|
||||
|
||||
// Bugfix for corepack auto-pinning, which adds yarnpkg to peoples' package.jsons
|
||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects
|
||||
process.env.COREPACK_ENABLE_AUTO_PIN = '0';
|
||||
process.env.COREPACK_ENABLE_AUTO_PIN = '0'
|
||||
|
||||
// Set max heap size for child processes in CCR environments (containers have 16GB)
|
||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level, custom-rules/safe-env-boolean-check
|
||||
if (process.env.CLAUDE_CODE_REMOTE === 'true') {
|
||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level
|
||||
const existing = process.env.NODE_OPTIONS || '';
|
||||
const existing = process.env.NODE_OPTIONS || ''
|
||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level
|
||||
process.env.NODE_OPTIONS = existing ? `${existing} --max-old-space-size=8192` : '--max-old-space-size=8192';
|
||||
process.env.NODE_OPTIONS = existing
|
||||
? `${existing} --max-old-space-size=8192`
|
||||
: '--max-old-space-size=8192'
|
||||
}
|
||||
|
||||
// Harness-science L0 ablation baseline. Inlined here (not init.ts) because
|
||||
@@ -30,7 +32,7 @@ if (feature('ABLATION_BASELINE') && process.env.CLAUDE_CODE_ABLATION_BASELINE) {
|
||||
'CLAUDE_CODE_DISABLE_BACKGROUND_TASKS',
|
||||
]) {
|
||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects, custom-rules/no-process-env-top-level
|
||||
process.env[k] ??= '1';
|
||||
process.env[k] ??= '1'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,51 +42,64 @@ if (feature('ABLATION_BASELINE') && process.env.CLAUDE_CODE_ABLATION_BASELINE) {
|
||||
* Fast-path for --version has zero imports beyond this file.
|
||||
*/
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const args = process.argv.slice(2)
|
||||
|
||||
// Fast-path for --version/-v: zero module loading needed
|
||||
if (args.length === 1 && (args[0] === '--version' || args[0] === '-v' || args[0] === '-V')) {
|
||||
if (
|
||||
args.length === 1 &&
|
||||
(args[0] === '--version' || args[0] === '-v' || args[0] === '-V')
|
||||
) {
|
||||
// MACRO.VERSION is inlined at build time
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(`${MACRO.VERSION} (Claude Code)`);
|
||||
return;
|
||||
console.log(`${MACRO.VERSION} (Claude Code)`)
|
||||
return
|
||||
}
|
||||
|
||||
// For all other paths, load the startup profiler
|
||||
const { profileCheckpoint } = await import('../utils/startupProfiler.js');
|
||||
profileCheckpoint('cli_entry');
|
||||
const { profileCheckpoint } = await import('../utils/startupProfiler.js')
|
||||
profileCheckpoint('cli_entry')
|
||||
|
||||
// Fast-path for --dump-system-prompt: output the rendered system prompt and exit.
|
||||
// Used by prompt sensitivity evals to extract the system prompt at a specific commit.
|
||||
// Ant-only: eliminated from external builds via feature flag.
|
||||
if (feature('DUMP_SYSTEM_PROMPT') && args[0] === '--dump-system-prompt') {
|
||||
profileCheckpoint('cli_dump_system_prompt_path');
|
||||
const { enableConfigs } = await import('../utils/config.js');
|
||||
enableConfigs();
|
||||
const { getMainLoopModel } = await import('../utils/model/model.js');
|
||||
const modelIdx = args.indexOf('--model');
|
||||
const model = (modelIdx !== -1 && args[modelIdx + 1]) || getMainLoopModel();
|
||||
const { getSystemPrompt } = await import('../constants/prompts.js');
|
||||
const prompt = await getSystemPrompt([], model);
|
||||
profileCheckpoint('cli_dump_system_prompt_path')
|
||||
const { enableConfigs } = await import('../utils/config.js')
|
||||
enableConfigs()
|
||||
const { getMainLoopModel } = await import('../utils/model/model.js')
|
||||
const modelIdx = args.indexOf('--model')
|
||||
const model = (modelIdx !== -1 && args[modelIdx + 1]) || getMainLoopModel()
|
||||
const { getSystemPrompt } = await import('../constants/prompts.js')
|
||||
const prompt = await getSystemPrompt([], model)
|
||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
||||
console.log(prompt.join('\n'));
|
||||
return;
|
||||
console.log(prompt.join('\n'))
|
||||
return
|
||||
}
|
||||
|
||||
if (process.argv[2] === '--claude-in-chrome-mcp') {
|
||||
profileCheckpoint('cli_claude_in_chrome_mcp_path');
|
||||
const { runClaudeInChromeMcpServer } = await import('../utils/claudeInChrome/mcpServer.js');
|
||||
await runClaudeInChromeMcpServer();
|
||||
return;
|
||||
profileCheckpoint('cli_claude_in_chrome_mcp_path')
|
||||
const { runClaudeInChromeMcpServer } = await import(
|
||||
'../utils/claudeInChrome/mcpServer.js'
|
||||
)
|
||||
await runClaudeInChromeMcpServer()
|
||||
return
|
||||
} else if (process.argv[2] === '--chrome-native-host') {
|
||||
profileCheckpoint('cli_chrome_native_host_path');
|
||||
const { runChromeNativeHost } = await import('../utils/claudeInChrome/chromeNativeHost.js');
|
||||
await runChromeNativeHost();
|
||||
return;
|
||||
} else if (feature('CHICAGO_MCP') && process.argv[2] === '--computer-use-mcp') {
|
||||
profileCheckpoint('cli_computer_use_mcp_path');
|
||||
const { runComputerUseMcpServer } = await import('../utils/computerUse/mcpServer.js');
|
||||
await runComputerUseMcpServer();
|
||||
return;
|
||||
profileCheckpoint('cli_chrome_native_host_path')
|
||||
const { runChromeNativeHost } = await import(
|
||||
'../utils/claudeInChrome/chromeNativeHost.js'
|
||||
)
|
||||
await runChromeNativeHost()
|
||||
return
|
||||
} else if (
|
||||
feature('CHICAGO_MCP') &&
|
||||
process.argv[2] === '--computer-use-mcp'
|
||||
) {
|
||||
profileCheckpoint('cli_computer_use_mcp_path')
|
||||
const { runComputerUseMcpServer } = await import(
|
||||
'../utils/computerUse/mcpServer.js'
|
||||
)
|
||||
await runComputerUseMcpServer()
|
||||
return
|
||||
}
|
||||
|
||||
// Fast-path for `--daemon-worker=<kind>` (internal — supervisor spawns this).
|
||||
@@ -93,9 +108,9 @@ async function main(): Promise<void> {
|
||||
// workers are lean. If a worker kind needs configs/auth (assistant will),
|
||||
// it calls them inside its run() fn.
|
||||
if (feature('DAEMON') && args[0] === '--daemon-worker') {
|
||||
const { runDaemonWorker } = await import('../daemon/workerRegistry.js');
|
||||
await runDaemonWorker(args[1]);
|
||||
return;
|
||||
const { runDaemonWorker } = await import('../daemon/workerRegistry.js')
|
||||
await runDaemonWorker(args[1])
|
||||
return
|
||||
}
|
||||
|
||||
// Fast-path for `claude remote-control` (also accepts legacy `claude remote` / `claude sync` / `claude bridge`):
|
||||
@@ -110,51 +125,59 @@ async function main(): Promise<void> {
|
||||
args[0] === 'sync' ||
|
||||
args[0] === 'bridge')
|
||||
) {
|
||||
profileCheckpoint('cli_bridge_path');
|
||||
const { enableConfigs } = await import('../utils/config.js');
|
||||
enableConfigs();
|
||||
const { getBridgeDisabledReason, checkBridgeMinVersion } = await import('../bridge/bridgeEnabled.js');
|
||||
const { BRIDGE_LOGIN_ERROR } = await import('../bridge/types.js');
|
||||
const { bridgeMain } = await import('../bridge/bridgeMain.js');
|
||||
const { exitWithError } = await import('../utils/process.js');
|
||||
profileCheckpoint('cli_bridge_path')
|
||||
const { enableConfigs } = await import('../utils/config.js')
|
||||
enableConfigs()
|
||||
|
||||
const { getBridgeDisabledReason, checkBridgeMinVersion } = await import(
|
||||
'../bridge/bridgeEnabled.js'
|
||||
)
|
||||
const { BRIDGE_LOGIN_ERROR } = await import('../bridge/types.js')
|
||||
const { bridgeMain } = await import('../bridge/bridgeMain.js')
|
||||
const { exitWithError } = await import('../utils/process.js')
|
||||
|
||||
// Auth check must come before the GrowthBook gate check — without auth,
|
||||
// GrowthBook has no user context and would return a stale/default false.
|
||||
// getBridgeDisabledReason awaits GB init, so the returned value is fresh
|
||||
// (not the stale disk cache), but init still needs auth headers to work.
|
||||
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js');
|
||||
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
|
||||
if (!getClaudeAIOAuthTokens()?.accessToken) {
|
||||
exitWithError(BRIDGE_LOGIN_ERROR);
|
||||
exitWithError(BRIDGE_LOGIN_ERROR)
|
||||
}
|
||||
const disabledReason = await getBridgeDisabledReason();
|
||||
const disabledReason = await getBridgeDisabledReason()
|
||||
if (disabledReason) {
|
||||
exitWithError(`Error: ${disabledReason}`);
|
||||
exitWithError(`Error: ${disabledReason}`)
|
||||
}
|
||||
const versionError = checkBridgeMinVersion();
|
||||
const versionError = checkBridgeMinVersion()
|
||||
if (versionError) {
|
||||
exitWithError(versionError);
|
||||
exitWithError(versionError)
|
||||
}
|
||||
|
||||
// Bridge is a remote control feature - check policy limits
|
||||
const { waitForPolicyLimitsToLoad, isPolicyAllowed } = await import('../services/policyLimits/index.js');
|
||||
await waitForPolicyLimitsToLoad();
|
||||
const { waitForPolicyLimitsToLoad, isPolicyAllowed } = await import(
|
||||
'../services/policyLimits/index.js'
|
||||
)
|
||||
await waitForPolicyLimitsToLoad()
|
||||
if (!isPolicyAllowed('allow_remote_control')) {
|
||||
exitWithError("Error: Remote Control is disabled by your organization's policy.");
|
||||
exitWithError(
|
||||
"Error: Remote Control is disabled by your organization's policy.",
|
||||
)
|
||||
}
|
||||
await bridgeMain(args.slice(1));
|
||||
return;
|
||||
|
||||
await bridgeMain(args.slice(1))
|
||||
return
|
||||
}
|
||||
|
||||
// Fast-path for `claude daemon [subcommand]`: long-running supervisor.
|
||||
if (feature('DAEMON') && args[0] === 'daemon') {
|
||||
profileCheckpoint('cli_daemon_path');
|
||||
const { enableConfigs } = await import('../utils/config.js');
|
||||
enableConfigs();
|
||||
const { initSinks } = await import('../utils/sinks.js');
|
||||
initSinks();
|
||||
const { daemonMain } = await import('../daemon/main.js');
|
||||
await daemonMain(args.slice(1));
|
||||
return;
|
||||
profileCheckpoint('cli_daemon_path')
|
||||
const { enableConfigs } = await import('../utils/config.js')
|
||||
enableConfigs()
|
||||
const { initSinks } = await import('../utils/sinks.js')
|
||||
initSinks()
|
||||
const { daemonMain } = await import('../daemon/main.js')
|
||||
await daemonMain(args.slice(1))
|
||||
return
|
||||
}
|
||||
|
||||
// Fast-path for `claude ps|logs|attach|kill` and `--bg`/`--background`.
|
||||
@@ -169,103 +192,117 @@ async function main(): Promise<void> {
|
||||
args.includes('--bg') ||
|
||||
args.includes('--background'))
|
||||
) {
|
||||
profileCheckpoint('cli_bg_path');
|
||||
const { enableConfigs } = await import('../utils/config.js');
|
||||
enableConfigs();
|
||||
const bg = await import('../cli/bg.js');
|
||||
profileCheckpoint('cli_bg_path')
|
||||
const { enableConfigs } = await import('../utils/config.js')
|
||||
enableConfigs()
|
||||
const bg = await import('../cli/bg.js')
|
||||
switch (args[0]) {
|
||||
case 'ps':
|
||||
await bg.psHandler(args.slice(1));
|
||||
break;
|
||||
await bg.psHandler(args.slice(1))
|
||||
break
|
||||
case 'logs':
|
||||
await bg.logsHandler(args[1]);
|
||||
break;
|
||||
await bg.logsHandler(args[1])
|
||||
break
|
||||
case 'attach':
|
||||
await bg.attachHandler(args[1]);
|
||||
break;
|
||||
await bg.attachHandler(args[1])
|
||||
break
|
||||
case 'kill':
|
||||
await bg.killHandler(args[1]);
|
||||
break;
|
||||
await bg.killHandler(args[1])
|
||||
break
|
||||
default:
|
||||
await bg.handleBgFlag(args);
|
||||
await bg.handleBgFlag(args)
|
||||
}
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
// Fast-path for template job commands.
|
||||
if (feature('TEMPLATES') && (args[0] === 'new' || args[0] === 'list' || args[0] === 'reply')) {
|
||||
profileCheckpoint('cli_templates_path');
|
||||
const { templatesMain } = await import('../cli/handlers/templateJobs.js');
|
||||
await templatesMain(args);
|
||||
if (
|
||||
feature('TEMPLATES') &&
|
||||
(args[0] === 'new' || args[0] === 'list' || args[0] === 'reply')
|
||||
) {
|
||||
profileCheckpoint('cli_templates_path')
|
||||
const { templatesMain } = await import('../cli/handlers/templateJobs.js')
|
||||
await templatesMain(args)
|
||||
// process.exit (not return) — mountFleetView's Ink TUI can leave event
|
||||
// loop handles that prevent natural exit.
|
||||
// eslint-disable-next-line custom-rules/no-process-exit
|
||||
process.exit(0);
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// Fast-path for `claude environment-runner`: headless BYOC runner.
|
||||
// feature() must stay inline for build-time dead code elimination.
|
||||
if (feature('BYOC_ENVIRONMENT_RUNNER') && args[0] === 'environment-runner') {
|
||||
profileCheckpoint('cli_environment_runner_path');
|
||||
const { environmentRunnerMain } = await import('../environment-runner/main.js');
|
||||
await environmentRunnerMain(args.slice(1));
|
||||
return;
|
||||
profileCheckpoint('cli_environment_runner_path')
|
||||
const { environmentRunnerMain } = await import(
|
||||
'../environment-runner/main.js'
|
||||
)
|
||||
await environmentRunnerMain(args.slice(1))
|
||||
return
|
||||
}
|
||||
|
||||
// Fast-path for `claude self-hosted-runner`: headless self-hosted-runner
|
||||
// targeting the SelfHostedRunnerWorkerService API (register + poll; poll IS
|
||||
// heartbeat). feature() must stay inline for build-time dead code elimination.
|
||||
if (feature('SELF_HOSTED_RUNNER') && args[0] === 'self-hosted-runner') {
|
||||
profileCheckpoint('cli_self_hosted_runner_path');
|
||||
const { selfHostedRunnerMain } = await import('../self-hosted-runner/main.js');
|
||||
await selfHostedRunnerMain(args.slice(1));
|
||||
return;
|
||||
profileCheckpoint('cli_self_hosted_runner_path')
|
||||
const { selfHostedRunnerMain } = await import(
|
||||
'../self-hosted-runner/main.js'
|
||||
)
|
||||
await selfHostedRunnerMain(args.slice(1))
|
||||
return
|
||||
}
|
||||
|
||||
// Fast-path for --worktree --tmux: exec into tmux before loading full CLI
|
||||
const hasTmuxFlag = args.includes('--tmux') || args.includes('--tmux=classic');
|
||||
const hasTmuxFlag = args.includes('--tmux') || args.includes('--tmux=classic')
|
||||
if (
|
||||
hasTmuxFlag &&
|
||||
(args.includes('-w') || args.includes('--worktree') || args.some(a => a.startsWith('--worktree=')))
|
||||
(args.includes('-w') ||
|
||||
args.includes('--worktree') ||
|
||||
args.some(a => a.startsWith('--worktree=')))
|
||||
) {
|
||||
profileCheckpoint('cli_tmux_worktree_fast_path');
|
||||
const { enableConfigs } = await import('../utils/config.js');
|
||||
enableConfigs();
|
||||
const { isWorktreeModeEnabled } = await import('../utils/worktreeModeEnabled.js');
|
||||
profileCheckpoint('cli_tmux_worktree_fast_path')
|
||||
const { enableConfigs } = await import('../utils/config.js')
|
||||
enableConfigs()
|
||||
const { isWorktreeModeEnabled } = await import(
|
||||
'../utils/worktreeModeEnabled.js'
|
||||
)
|
||||
if (isWorktreeModeEnabled()) {
|
||||
const { execIntoTmuxWorktree } = await import('../utils/worktree.js');
|
||||
const result = await execIntoTmuxWorktree(args);
|
||||
const { execIntoTmuxWorktree } = await import('../utils/worktree.js')
|
||||
const result = await execIntoTmuxWorktree(args)
|
||||
if (result.handled) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
// If not handled (e.g., error), fall through to normal CLI
|
||||
if (result.error) {
|
||||
const { exitWithError } = await import('../utils/process.js');
|
||||
exitWithError(result.error);
|
||||
const { exitWithError } = await import('../utils/process.js')
|
||||
exitWithError(result.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect common update flag mistakes to the update subcommand
|
||||
if (args.length === 1 && (args[0] === '--update' || args[0] === '--upgrade')) {
|
||||
process.argv = [process.argv[0]!, process.argv[1]!, 'update'];
|
||||
if (
|
||||
args.length === 1 &&
|
||||
(args[0] === '--update' || args[0] === '--upgrade')
|
||||
) {
|
||||
process.argv = [process.argv[0]!, process.argv[1]!, 'update']
|
||||
}
|
||||
|
||||
// --bare: set SIMPLE early so gates fire during module eval / commander
|
||||
// option building (not just inside the action handler).
|
||||
if (args.includes('--bare')) {
|
||||
process.env.CLAUDE_CODE_SIMPLE = '1';
|
||||
process.env.CLAUDE_CODE_SIMPLE = '1'
|
||||
}
|
||||
|
||||
// No special flags detected, load and run the full CLI
|
||||
const { startCapturingEarlyInput } = await import('../utils/earlyInput.js');
|
||||
startCapturingEarlyInput();
|
||||
profileCheckpoint('cli_before_main_import');
|
||||
const { main: cliMain } = await import('../main.jsx');
|
||||
profileCheckpoint('cli_after_main_import');
|
||||
await cliMain();
|
||||
profileCheckpoint('cli_after_main_complete');
|
||||
const { startCapturingEarlyInput } = await import('../utils/earlyInput.js')
|
||||
startCapturingEarlyInput()
|
||||
profileCheckpoint('cli_before_main_import')
|
||||
const { main: cliMain } = await import('../main.jsx')
|
||||
profileCheckpoint('cli_after_main_import')
|
||||
await cliMain()
|
||||
profileCheckpoint('cli_after_main_complete')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line custom-rules/no-top-level-side-effects
|
||||
void main();
|
||||
void main()
|
||||
|
||||
@@ -1,46 +1,76 @@
|
||||
import { feature } from 'bun:bundle';
|
||||
import { appendFileSync } from 'fs';
|
||||
import React from 'react';
|
||||
import { logEvent } from 'src/services/analytics/index.js';
|
||||
import { gracefulShutdown, gracefulShutdownSync } from 'src/utils/gracefulShutdown.js';
|
||||
import { type ChannelEntry, getAllowedChannels, setAllowedChannels, setHasDevChannels, setSessionTrustAccepted, setStatsStore } from './bootstrap/state.js';
|
||||
import type { Command } from './commands.js';
|
||||
import { createStatsStore, type StatsStore } from './context/stats.js';
|
||||
import { getSystemContext } from './context.js';
|
||||
import { initializeTelemetryAfterTrust } from './entrypoints/init.js';
|
||||
import { isSynchronizedOutputSupported } from './ink/terminal.js';
|
||||
import type { RenderOptions, Root, TextProps } from './ink.js';
|
||||
import { KeybindingSetup } from './keybindings/KeybindingProviderSetup.js';
|
||||
import { startDeferredPrefetches } from './main.js';
|
||||
import { checkGate_CACHED_OR_BLOCKING, initializeGrowthBook, resetGrowthBook } from './services/analytics/growthbook.js';
|
||||
import { isQualifiedForGrove } from './services/api/grove.js';
|
||||
import { handleMcpjsonServerApprovals } from './services/mcpServerApproval.js';
|
||||
import { AppStateProvider } from './state/AppState.js';
|
||||
import { onChangeAppState } from './state/onChangeAppState.js';
|
||||
import { normalizeApiKeyForConfig } from './utils/authPortable.js';
|
||||
import { getExternalClaudeMdIncludes, getMemoryFiles, shouldShowClaudeMdExternalIncludesWarning } from './utils/claudemd.js';
|
||||
import { checkHasTrustDialogAccepted, getCustomApiKeyStatus, getGlobalConfig, saveGlobalConfig } from './utils/config.js';
|
||||
import { updateDeepLinkTerminalPreference } from './utils/deepLink/terminalPreference.js';
|
||||
import { isEnvTruthy, isRunningOnHomespace } from './utils/envUtils.js';
|
||||
import { type FpsMetrics, FpsTracker } from './utils/fpsTracker.js';
|
||||
import { updateGithubRepoPathMapping } from './utils/githubRepoPathMapping.js';
|
||||
import { applyConfigEnvironmentVariables } from './utils/managedEnv.js';
|
||||
import type { PermissionMode } from './utils/permissions/PermissionMode.js';
|
||||
import { getBaseRenderOptions } from './utils/renderOptions.js';
|
||||
import { getSettingsWithAllErrors } from './utils/settings/allErrors.js';
|
||||
import { hasAutoModeOptIn, hasSkipDangerousModePermissionPrompt } from './utils/settings/settings.js';
|
||||
import { feature } from 'bun:bundle'
|
||||
import { appendFileSync } from 'fs'
|
||||
import React from 'react'
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import {
|
||||
gracefulShutdown,
|
||||
gracefulShutdownSync,
|
||||
} from 'src/utils/gracefulShutdown.js'
|
||||
import {
|
||||
type ChannelEntry,
|
||||
getAllowedChannels,
|
||||
setAllowedChannels,
|
||||
setHasDevChannels,
|
||||
setSessionTrustAccepted,
|
||||
setStatsStore,
|
||||
} from './bootstrap/state.js'
|
||||
import type { Command } from './commands.js'
|
||||
import { createStatsStore, type StatsStore } from './context/stats.js'
|
||||
import { getSystemContext } from './context.js'
|
||||
import { initializeTelemetryAfterTrust } from './entrypoints/init.js'
|
||||
import { isSynchronizedOutputSupported } from './ink/terminal.js'
|
||||
import type { RenderOptions, Root, TextProps } from './ink.js'
|
||||
import { KeybindingSetup } from './keybindings/KeybindingProviderSetup.js'
|
||||
import { startDeferredPrefetches } from './main.js'
|
||||
import {
|
||||
checkGate_CACHED_OR_BLOCKING,
|
||||
initializeGrowthBook,
|
||||
resetGrowthBook,
|
||||
} from './services/analytics/growthbook.js'
|
||||
import { isQualifiedForGrove } from './services/api/grove.js'
|
||||
import { handleMcpjsonServerApprovals } from './services/mcpServerApproval.js'
|
||||
import { AppStateProvider } from './state/AppState.js'
|
||||
import { onChangeAppState } from './state/onChangeAppState.js'
|
||||
import { normalizeApiKeyForConfig } from './utils/authPortable.js'
|
||||
import {
|
||||
getExternalClaudeMdIncludes,
|
||||
getMemoryFiles,
|
||||
shouldShowClaudeMdExternalIncludesWarning,
|
||||
} from './utils/claudemd.js'
|
||||
import {
|
||||
checkHasTrustDialogAccepted,
|
||||
getCustomApiKeyStatus,
|
||||
getGlobalConfig,
|
||||
saveGlobalConfig,
|
||||
} from './utils/config.js'
|
||||
import { updateDeepLinkTerminalPreference } from './utils/deepLink/terminalPreference.js'
|
||||
import { isEnvTruthy, isRunningOnHomespace } from './utils/envUtils.js'
|
||||
import { type FpsMetrics, FpsTracker } from './utils/fpsTracker.js'
|
||||
import { updateGithubRepoPathMapping } from './utils/githubRepoPathMapping.js'
|
||||
import { applyConfigEnvironmentVariables } from './utils/managedEnv.js'
|
||||
import type { PermissionMode } from './utils/permissions/PermissionMode.js'
|
||||
import { getBaseRenderOptions } from './utils/renderOptions.js'
|
||||
import { getSettingsWithAllErrors } from './utils/settings/allErrors.js'
|
||||
import {
|
||||
hasAutoModeOptIn,
|
||||
hasSkipDangerousModePermissionPrompt,
|
||||
} from './utils/settings/settings.js'
|
||||
|
||||
export function completeOnboarding(): void {
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
hasCompletedOnboarding: true,
|
||||
lastOnboardingVersion: MACRO.VERSION
|
||||
}));
|
||||
lastOnboardingVersion: MACRO.VERSION,
|
||||
}))
|
||||
}
|
||||
export function showDialog<T = void>(root: Root, renderer: (done: (result: T) => void) => React.ReactNode): Promise<T> {
|
||||
export function showDialog<T = void>(
|
||||
root: Root,
|
||||
renderer: (done: (result: T) => void) => React.ReactNode,
|
||||
): Promise<T> {
|
||||
return new Promise<T>(resolve => {
|
||||
const done = (result: T): void => void resolve(result);
|
||||
root.render(renderer(done));
|
||||
});
|
||||
const done = (result: T): void => void resolve(result)
|
||||
root.render(renderer(done))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,11 +79,12 @@ export function showDialog<T = void>(root: Root, renderer: (done: (result: T) =>
|
||||
* console.error is swallowed by Ink's patchConsole, so we render
|
||||
* through the React tree instead.
|
||||
*/
|
||||
export async function exitWithError(root: Root, message: string, beforeExit?: () => Promise<void>): Promise<never> {
|
||||
return exitWithMessage(root, message, {
|
||||
color: 'error',
|
||||
beforeExit
|
||||
});
|
||||
export async function exitWithError(
|
||||
root: Root,
|
||||
message: string,
|
||||
beforeExit?: () => Promise<void>,
|
||||
): Promise<never> {
|
||||
return exitWithMessage(root, message, { color: 'error', beforeExit })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,64 +93,93 @@ export async function exitWithError(root: Root, message: string, beforeExit?: ()
|
||||
* console output is swallowed by Ink's patchConsole, so we render
|
||||
* through the React tree instead.
|
||||
*/
|
||||
export async function exitWithMessage(root: Root, message: string, options?: {
|
||||
color?: TextProps['color'];
|
||||
exitCode?: number;
|
||||
beforeExit?: () => Promise<void>;
|
||||
}): Promise<never> {
|
||||
const {
|
||||
Text
|
||||
} = await import('./ink.js');
|
||||
const color = options?.color;
|
||||
const exitCode = options?.exitCode ?? 1;
|
||||
root.render(color ? <Text color={color}>{message}</Text> : <Text>{message}</Text>);
|
||||
root.unmount();
|
||||
await options?.beforeExit?.();
|
||||
export async function exitWithMessage(
|
||||
root: Root,
|
||||
message: string,
|
||||
options?: {
|
||||
color?: TextProps['color']
|
||||
exitCode?: number
|
||||
beforeExit?: () => Promise<void>
|
||||
},
|
||||
): Promise<never> {
|
||||
const { Text } = await import('./ink.js')
|
||||
const color = options?.color
|
||||
const exitCode = options?.exitCode ?? 1
|
||||
root.render(
|
||||
color ? <Text color={color}>{message}</Text> : <Text>{message}</Text>,
|
||||
)
|
||||
root.unmount()
|
||||
await options?.beforeExit?.()
|
||||
// eslint-disable-next-line custom-rules/no-process-exit -- exit after Ink unmount
|
||||
process.exit(exitCode);
|
||||
process.exit(exitCode)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a setup dialog wrapped in AppStateProvider + KeybindingSetup.
|
||||
* Reduces boilerplate in showSetupScreens() where every dialog needs these wrappers.
|
||||
*/
|
||||
export function showSetupDialog<T = void>(root: Root, renderer: (done: (result: T) => void) => React.ReactNode, options?: {
|
||||
onChangeAppState?: typeof onChangeAppState;
|
||||
}): Promise<T> {
|
||||
return showDialog<T>(root, done => <AppStateProvider onChangeAppState={options?.onChangeAppState}>
|
||||
export function showSetupDialog<T = void>(
|
||||
root: Root,
|
||||
renderer: (done: (result: T) => void) => React.ReactNode,
|
||||
options?: { onChangeAppState?: typeof onChangeAppState },
|
||||
): Promise<T> {
|
||||
return showDialog<T>(root, done => (
|
||||
<AppStateProvider onChangeAppState={options?.onChangeAppState}>
|
||||
<KeybindingSetup>{renderer(done)}</KeybindingSetup>
|
||||
</AppStateProvider>);
|
||||
</AppStateProvider>
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the main UI into the root and wait for it to exit.
|
||||
* Handles the common epilogue: start deferred prefetches, wait for exit, graceful shutdown.
|
||||
*/
|
||||
export async function renderAndRun(root: Root, element: React.ReactNode): Promise<void> {
|
||||
root.render(element);
|
||||
startDeferredPrefetches();
|
||||
await root.waitUntilExit();
|
||||
await gracefulShutdown(0);
|
||||
export async function renderAndRun(
|
||||
root: Root,
|
||||
element: React.ReactNode,
|
||||
): Promise<void> {
|
||||
root.render(element)
|
||||
startDeferredPrefetches()
|
||||
await root.waitUntilExit()
|
||||
await gracefulShutdown(0)
|
||||
}
|
||||
export async function showSetupScreens(root: Root, permissionMode: PermissionMode, allowDangerouslySkipPermissions: boolean, commands?: Command[], claudeInChrome?: boolean, devChannels?: ChannelEntry[]): Promise<boolean> {
|
||||
if (("production" as string) === 'test' || isEnvTruthy(false) || process.env.IS_DEMO // Skip onboarding in demo mode
|
||||
|
||||
export async function showSetupScreens(
|
||||
root: Root,
|
||||
permissionMode: PermissionMode,
|
||||
allowDangerouslySkipPermissions: boolean,
|
||||
commands?: Command[],
|
||||
claudeInChrome?: boolean,
|
||||
devChannels?: ChannelEntry[],
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
"production" === 'test' ||
|
||||
isEnvTruthy(false) ||
|
||||
process.env.IS_DEMO // Skip onboarding in demo mode
|
||||
) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
const config = getGlobalConfig();
|
||||
let onboardingShown = false;
|
||||
if (!config.theme || !config.hasCompletedOnboarding // always show onboarding at least once
|
||||
|
||||
const config = getGlobalConfig()
|
||||
let onboardingShown = false
|
||||
if (
|
||||
!config.theme ||
|
||||
!config.hasCompletedOnboarding // always show onboarding at least once
|
||||
) {
|
||||
onboardingShown = true;
|
||||
const {
|
||||
Onboarding
|
||||
} = await import('./components/Onboarding.js');
|
||||
await showSetupDialog(root, done => <Onboarding onDone={() => {
|
||||
completeOnboarding();
|
||||
void done();
|
||||
}} />, {
|
||||
onChangeAppState
|
||||
});
|
||||
onboardingShown = true
|
||||
const { Onboarding } = await import('./components/Onboarding.js')
|
||||
await showSetupDialog(
|
||||
root,
|
||||
done => (
|
||||
<Onboarding
|
||||
onDone={() => {
|
||||
completeOnboarding()
|
||||
void done()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
{ onChangeAppState },
|
||||
)
|
||||
}
|
||||
|
||||
// Always show the trust dialog in interactive sessions, regardless of permission mode.
|
||||
@@ -133,70 +193,83 @@ export async function showSetupScreens(root: Root, permissionMode: PermissionMod
|
||||
// If it returns true, the TrustDialog would auto-resolve regardless of
|
||||
// security features, so we can skip the dynamic import and render cycle.
|
||||
if (!checkHasTrustDialogAccepted()) {
|
||||
const {
|
||||
TrustDialog
|
||||
} = await import('./components/TrustDialog/TrustDialog.js');
|
||||
await showSetupDialog(root, done => <TrustDialog commands={commands} onDone={done} />);
|
||||
const { TrustDialog } = await import(
|
||||
'./components/TrustDialog/TrustDialog.js'
|
||||
)
|
||||
await showSetupDialog(root, done => (
|
||||
<TrustDialog commands={commands} onDone={done} />
|
||||
))
|
||||
}
|
||||
|
||||
// Signal that trust has been verified for this session.
|
||||
// GrowthBook checks this to decide whether to include auth headers.
|
||||
setSessionTrustAccepted(true);
|
||||
setSessionTrustAccepted(true)
|
||||
|
||||
// Reset and reinitialize GrowthBook after trust is established.
|
||||
// Defense for login/logout: clears any prior client so the next init
|
||||
// picks up fresh auth headers.
|
||||
resetGrowthBook();
|
||||
void initializeGrowthBook();
|
||||
resetGrowthBook()
|
||||
void initializeGrowthBook()
|
||||
|
||||
// Now that trust is established, prefetch system context if it wasn't already
|
||||
void getSystemContext();
|
||||
void getSystemContext()
|
||||
|
||||
// If settings are valid, check for any mcp.json servers that need approval
|
||||
const {
|
||||
errors: allErrors
|
||||
} = getSettingsWithAllErrors();
|
||||
const { errors: allErrors } = getSettingsWithAllErrors()
|
||||
if (allErrors.length === 0) {
|
||||
await handleMcpjsonServerApprovals(root);
|
||||
await handleMcpjsonServerApprovals(root)
|
||||
}
|
||||
|
||||
// Check for claude.md includes that need approval
|
||||
if (await shouldShowClaudeMdExternalIncludesWarning()) {
|
||||
const externalIncludes = getExternalClaudeMdIncludes(await getMemoryFiles(true));
|
||||
const {
|
||||
ClaudeMdExternalIncludesDialog
|
||||
} = await import('./components/ClaudeMdExternalIncludesDialog.js');
|
||||
await showSetupDialog(root, done => <ClaudeMdExternalIncludesDialog onDone={done} isStandaloneDialog externalIncludes={externalIncludes} />);
|
||||
const externalIncludes = getExternalClaudeMdIncludes(
|
||||
await getMemoryFiles(true),
|
||||
)
|
||||
const { ClaudeMdExternalIncludesDialog } = await import(
|
||||
'./components/ClaudeMdExternalIncludesDialog.js'
|
||||
)
|
||||
await showSetupDialog(root, done => (
|
||||
<ClaudeMdExternalIncludesDialog
|
||||
onDone={done}
|
||||
isStandaloneDialog
|
||||
externalIncludes={externalIncludes}
|
||||
/>
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Track current repo path for teleport directory switching (fire-and-forget)
|
||||
// This must happen AFTER trust to prevent untrusted directories from poisoning the mapping
|
||||
void updateGithubRepoPathMapping();
|
||||
void updateGithubRepoPathMapping()
|
||||
if (feature('LODESTONE')) {
|
||||
updateDeepLinkTerminalPreference();
|
||||
updateDeepLinkTerminalPreference()
|
||||
}
|
||||
|
||||
// Apply full environment variables after trust dialog is accepted OR in bypass mode
|
||||
// In bypass mode (CI/CD, automation), we trust the environment so apply all variables
|
||||
// In normal mode, this happens after the trust dialog is accepted
|
||||
// This includes potentially dangerous environment variables from untrusted sources
|
||||
applyConfigEnvironmentVariables();
|
||||
applyConfigEnvironmentVariables()
|
||||
|
||||
// Initialize telemetry after env vars are applied so OTEL endpoint env vars and
|
||||
// otelHeadersHelper (which requires trust to execute) are available.
|
||||
// Defer to next tick so the OTel dynamic import resolves after first render
|
||||
// instead of during the pre-render microtask queue.
|
||||
setImmediate(() => initializeTelemetryAfterTrust());
|
||||
setImmediate(() => initializeTelemetryAfterTrust())
|
||||
|
||||
if (await isQualifiedForGrove()) {
|
||||
const {
|
||||
GroveDialog
|
||||
} = await import('src/components/grove/Grove.js');
|
||||
const decision = await showSetupDialog<string>(root, done => <GroveDialog showIfAlreadyViewed={false} location={onboardingShown ? 'onboarding' : 'policy_update_modal'} onDone={done} />);
|
||||
const { GroveDialog } = await import('src/components/grove/Grove.js')
|
||||
const decision = await showSetupDialog<string>(root, done => (
|
||||
<GroveDialog
|
||||
showIfAlreadyViewed={false}
|
||||
location={onboardingShown ? 'onboarding' : 'policy_update_modal'}
|
||||
onDone={done}
|
||||
/>
|
||||
))
|
||||
if (decision === 'escape') {
|
||||
logEvent('tengu_grove_policy_exited', {});
|
||||
gracefulShutdownSync(0);
|
||||
return false;
|
||||
logEvent('tengu_grove_policy_exited', {})
|
||||
gracefulShutdownSync(0)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,33 +277,54 @@ export async function showSetupScreens(root: Root, permissionMode: PermissionMod
|
||||
// On homespace, ANTHROPIC_API_KEY is preserved in process.env for child
|
||||
// processes but ignored by Claude Code itself (see auth.ts).
|
||||
if (process.env.ANTHROPIC_API_KEY && !isRunningOnHomespace()) {
|
||||
const customApiKeyTruncated = normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY);
|
||||
const keyStatus = getCustomApiKeyStatus(customApiKeyTruncated);
|
||||
const customApiKeyTruncated = normalizeApiKeyForConfig(
|
||||
process.env.ANTHROPIC_API_KEY,
|
||||
)
|
||||
const keyStatus = getCustomApiKeyStatus(customApiKeyTruncated)
|
||||
if (keyStatus === 'new') {
|
||||
const {
|
||||
ApproveApiKey
|
||||
} = await import('./components/ApproveApiKey.js');
|
||||
await showSetupDialog<boolean>(root, done => <ApproveApiKey customApiKeyTruncated={customApiKeyTruncated} onDone={done} />, {
|
||||
onChangeAppState
|
||||
});
|
||||
const { ApproveApiKey } = await import('./components/ApproveApiKey.js')
|
||||
await showSetupDialog<boolean>(
|
||||
root,
|
||||
done => (
|
||||
<ApproveApiKey
|
||||
customApiKeyTruncated={customApiKeyTruncated}
|
||||
onDone={done}
|
||||
/>
|
||||
),
|
||||
{ onChangeAppState },
|
||||
)
|
||||
}
|
||||
}
|
||||
if ((permissionMode === 'bypassPermissions' || allowDangerouslySkipPermissions) && !hasSkipDangerousModePermissionPrompt()) {
|
||||
const {
|
||||
BypassPermissionsModeDialog
|
||||
} = await import('./components/BypassPermissionsModeDialog.js');
|
||||
await showSetupDialog(root, done => <BypassPermissionsModeDialog onAccept={done} />);
|
||||
|
||||
if (
|
||||
(permissionMode === 'bypassPermissions' ||
|
||||
allowDangerouslySkipPermissions) &&
|
||||
!hasSkipDangerousModePermissionPrompt()
|
||||
) {
|
||||
const { BypassPermissionsModeDialog } = await import(
|
||||
'./components/BypassPermissionsModeDialog.js'
|
||||
)
|
||||
await showSetupDialog(root, done => (
|
||||
<BypassPermissionsModeDialog onAccept={done} />
|
||||
))
|
||||
}
|
||||
|
||||
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
||||
// Only show the opt-in dialog if auto mode actually resolved — if the
|
||||
// gate denied it (org not allowlisted, settings disabled), showing
|
||||
// consent for an unavailable feature is pointless. The
|
||||
// verifyAutoModeGateAccess notification will explain why instead.
|
||||
if (permissionMode === 'auto' && !hasAutoModeOptIn()) {
|
||||
const {
|
||||
AutoModeOptInDialog
|
||||
} = await import('./components/AutoModeOptInDialog.js');
|
||||
await showSetupDialog(root, done => <AutoModeOptInDialog onAccept={done} onDecline={() => gracefulShutdownSync(1)} declineExits />);
|
||||
const { AutoModeOptInDialog } = await import(
|
||||
'./components/AutoModeOptInDialog.js'
|
||||
)
|
||||
await showSetupDialog(root, done => (
|
||||
<AutoModeOptInDialog
|
||||
onAccept={done}
|
||||
onDecline={() => gracefulShutdownSync(1)}
|
||||
declineExits
|
||||
/>
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,14 +342,15 @@ export async function showSetupScreens(root: Root, permissionMode: PermissionMod
|
||||
// initializeGrowthBook promise fired earlier). Also warms the
|
||||
// isChannelsEnabled() check in the dev-channels dialog below.
|
||||
if (getAllowedChannels().length > 0 || (devChannels?.length ?? 0) > 0) {
|
||||
await checkGate_CACHED_OR_BLOCKING('tengu_harbor');
|
||||
await checkGate_CACHED_OR_BLOCKING('tengu_harbor')
|
||||
}
|
||||
|
||||
if (devChannels && devChannels.length > 0) {
|
||||
const [{
|
||||
isChannelsEnabled
|
||||
}, {
|
||||
getClaudeAIOAuthTokens
|
||||
}] = await Promise.all([import('./services/mcp/channelAllowlist.js'), import('./utils/auth.js')]);
|
||||
const [{ isChannelsEnabled }, { getClaudeAIOAuthTokens }] =
|
||||
await Promise.all([
|
||||
import('./services/mcp/channelAllowlist.js'),
|
||||
import('./utils/auth.js'),
|
||||
])
|
||||
// Skip the dialog when channels are blocked (tengu_harbor off or no
|
||||
// OAuth) — accepting then immediately seeing "not available" in
|
||||
// ChannelsNotice is worse than no dialog. Append entries anyway so
|
||||
@@ -264,102 +359,115 @@ export async function showSetupScreens(root: Root, permissionMode: PermissionMod
|
||||
// (hasNonDev check); the allowlist bypass it also grants is moot
|
||||
// since the gate blocks upstream.
|
||||
if (!isChannelsEnabled() || !getClaudeAIOAuthTokens()?.accessToken) {
|
||||
setAllowedChannels([...getAllowedChannels(), ...devChannels.map(c => ({
|
||||
...c,
|
||||
dev: true
|
||||
}))]);
|
||||
setHasDevChannels(true);
|
||||
setAllowedChannels([
|
||||
...getAllowedChannels(),
|
||||
...devChannels.map(c => ({ ...c, dev: true })),
|
||||
])
|
||||
setHasDevChannels(true)
|
||||
} else {
|
||||
const {
|
||||
DevChannelsDialog
|
||||
} = await import('./components/DevChannelsDialog.js');
|
||||
await showSetupDialog(root, done => <DevChannelsDialog channels={devChannels} onAccept={() => {
|
||||
// Mark dev entries per-entry so the allowlist bypass doesn't leak
|
||||
// to --channels entries when both flags are passed.
|
||||
setAllowedChannels([...getAllowedChannels(), ...devChannels.map(c => ({
|
||||
...c,
|
||||
dev: true
|
||||
}))]);
|
||||
setHasDevChannels(true);
|
||||
void done();
|
||||
}} />);
|
||||
const { DevChannelsDialog } = await import(
|
||||
'./components/DevChannelsDialog.js'
|
||||
)
|
||||
await showSetupDialog(root, done => (
|
||||
<DevChannelsDialog
|
||||
channels={devChannels}
|
||||
onAccept={() => {
|
||||
// Mark dev entries per-entry so the allowlist bypass doesn't leak
|
||||
// to --channels entries when both flags are passed.
|
||||
setAllowedChannels([
|
||||
...getAllowedChannels(),
|
||||
...devChannels.map(c => ({ ...c, dev: true })),
|
||||
])
|
||||
setHasDevChannels(true)
|
||||
void done()
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show Chrome onboarding for first-time Claude in Chrome users
|
||||
if (claudeInChrome && !getGlobalConfig().hasCompletedClaudeInChromeOnboarding) {
|
||||
const {
|
||||
ClaudeInChromeOnboarding
|
||||
} = await import('./components/ClaudeInChromeOnboarding.js');
|
||||
await showSetupDialog(root, done => <ClaudeInChromeOnboarding onDone={done} />);
|
||||
if (
|
||||
claudeInChrome &&
|
||||
!getGlobalConfig().hasCompletedClaudeInChromeOnboarding
|
||||
) {
|
||||
const { ClaudeInChromeOnboarding } = await import(
|
||||
'./components/ClaudeInChromeOnboarding.js'
|
||||
)
|
||||
await showSetupDialog(root, done => (
|
||||
<ClaudeInChromeOnboarding onDone={done} />
|
||||
))
|
||||
}
|
||||
return onboardingShown;
|
||||
|
||||
return onboardingShown
|
||||
}
|
||||
|
||||
export function getRenderContext(exitOnCtrlC: boolean): {
|
||||
renderOptions: RenderOptions;
|
||||
getFpsMetrics: () => FpsMetrics | undefined;
|
||||
stats: StatsStore;
|
||||
renderOptions: RenderOptions
|
||||
getFpsMetrics: () => FpsMetrics | undefined
|
||||
stats: StatsStore
|
||||
} {
|
||||
let lastFlickerTime = 0;
|
||||
const baseOptions = getBaseRenderOptions(exitOnCtrlC);
|
||||
let lastFlickerTime = 0
|
||||
const baseOptions = getBaseRenderOptions(exitOnCtrlC)
|
||||
|
||||
// Log analytics event when stdin override is active
|
||||
if (baseOptions.stdin) {
|
||||
logEvent('tengu_stdin_interactive', {});
|
||||
logEvent('tengu_stdin_interactive', {})
|
||||
}
|
||||
const fpsTracker = new FpsTracker();
|
||||
const stats = createStatsStore();
|
||||
setStatsStore(stats);
|
||||
|
||||
const fpsTracker = new FpsTracker()
|
||||
const stats = createStatsStore()
|
||||
setStatsStore(stats)
|
||||
|
||||
// Bench mode: when set, append per-frame phase timings as JSONL for
|
||||
// offline analysis by bench/repl-scroll.ts. Captures the full TUI
|
||||
// render pipeline (yoga → screen buffer → diff → optimize → stdout)
|
||||
// so perf work on any phase can be validated against real user flows.
|
||||
const frameTimingLogPath = process.env.CLAUDE_CODE_FRAME_TIMING_LOG;
|
||||
const frameTimingLogPath = process.env.CLAUDE_CODE_FRAME_TIMING_LOG
|
||||
return {
|
||||
getFpsMetrics: () => fpsTracker.getMetrics(),
|
||||
stats,
|
||||
renderOptions: {
|
||||
...baseOptions,
|
||||
onFrame: event => {
|
||||
fpsTracker.record(event.durationMs);
|
||||
stats.observe('frame_duration_ms', event.durationMs);
|
||||
fpsTracker.record(event.durationMs)
|
||||
stats.observe('frame_duration_ms', event.durationMs)
|
||||
if (frameTimingLogPath && event.phases) {
|
||||
// Bench-only env-var-gated path: sync write so no frames dropped
|
||||
// on abrupt exit. ~100 bytes at ≤60fps is negligible. rss/cpu are
|
||||
// single syscalls; cpu is cumulative — bench side computes delta.
|
||||
const line =
|
||||
// eslint-disable-next-line custom-rules/no-direct-json-operations -- tiny object, hot bench path
|
||||
JSON.stringify({
|
||||
total: event.durationMs,
|
||||
...event.phases,
|
||||
rss: process.memoryUsage.rss(),
|
||||
cpu: process.cpuUsage()
|
||||
}) + '\n';
|
||||
// eslint-disable-next-line custom-rules/no-direct-json-operations -- tiny object, hot bench path
|
||||
JSON.stringify({
|
||||
total: event.durationMs,
|
||||
...event.phases,
|
||||
rss: process.memoryUsage.rss(),
|
||||
cpu: process.cpuUsage(),
|
||||
}) + '\n'
|
||||
// eslint-disable-next-line custom-rules/no-sync-fs -- bench-only, sync so no frames dropped on exit
|
||||
appendFileSync(frameTimingLogPath, line);
|
||||
appendFileSync(frameTimingLogPath, line)
|
||||
}
|
||||
// Skip flicker reporting for terminals with synchronized output —
|
||||
// DEC 2026 buffers between BSU/ESU so clear+redraw is atomic.
|
||||
if (isSynchronizedOutputSupported()) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
for (const flicker of event.flickers) {
|
||||
if (flicker.reason === 'resize') {
|
||||
continue;
|
||||
continue
|
||||
}
|
||||
const now = Date.now();
|
||||
const now = Date.now()
|
||||
if (now - lastFlickerTime < 1000) {
|
||||
logEvent('tengu_flicker', {
|
||||
desiredHeight: flicker.desiredHeight,
|
||||
actualHeight: flicker.availableHeight,
|
||||
reason: flicker.reason
|
||||
} as unknown as Record<string, boolean | number | undefined>);
|
||||
reason: flicker.reason,
|
||||
} as unknown as Record<string, boolean | number | undefined>)
|
||||
}
|
||||
lastFlickerTime = now;
|
||||
lastFlickerTime = now
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
9783
src/main.tsx
9783
src/main.tsx
File diff suppressed because it is too large
Load Diff
@@ -5,21 +5,22 @@
|
||||
// would resolve to scripts/external-stubs/src/types/ (doesn't exist).
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type M = any;
|
||||
type M = any
|
||||
|
||||
export function useMoreRight(_args: {
|
||||
enabled: boolean;
|
||||
setMessages: (action: M[] | ((prev: M[]) => M[])) => void;
|
||||
inputValue: string;
|
||||
setInputValue: (s: string) => void;
|
||||
setToolJSX: (args: M) => void;
|
||||
enabled: boolean
|
||||
setMessages: (action: M[] | ((prev: M[]) => M[])) => void
|
||||
inputValue: string
|
||||
setInputValue: (s: string) => void
|
||||
setToolJSX: (args: M) => void
|
||||
}): {
|
||||
onBeforeQuery: (input: string, all: M[], n: number) => Promise<boolean>;
|
||||
onTurnComplete: (all: M[], aborted: boolean) => Promise<void>;
|
||||
render: () => null;
|
||||
onBeforeQuery: (input: string, all: M[], n: number) => Promise<boolean>
|
||||
onTurnComplete: (all: M[], aborted: boolean) => Promise<void>
|
||||
render: () => null
|
||||
} {
|
||||
return {
|
||||
onBeforeQuery: async () => true,
|
||||
onTurnComplete: async () => {},
|
||||
render: () => null
|
||||
};
|
||||
render: () => null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import React from 'react';
|
||||
import type { StatsStore } from './context/stats.js';
|
||||
import type { Root } from './ink.js';
|
||||
import type { Props as REPLProps } from './screens/REPL.js';
|
||||
import type { AppState } from './state/AppStateStore.js';
|
||||
import type { FpsMetrics } from './utils/fpsTracker.js';
|
||||
import React from 'react'
|
||||
import type { StatsStore } from './context/stats.js'
|
||||
import type { Root } from './ink.js'
|
||||
import type { Props as REPLProps } from './screens/REPL.js'
|
||||
import type { AppState } from './state/AppStateStore.js'
|
||||
import type { FpsMetrics } from './utils/fpsTracker.js'
|
||||
|
||||
type AppWrapperProps = {
|
||||
getFpsMetrics: () => FpsMetrics | undefined;
|
||||
stats?: StatsStore;
|
||||
initialState: AppState;
|
||||
};
|
||||
export async function launchRepl(root: Root, appProps: AppWrapperProps, replProps: REPLProps, renderAndRun: (root: Root, element: React.ReactNode) => Promise<void>): Promise<void> {
|
||||
const {
|
||||
App
|
||||
} = await import('./components/App.js');
|
||||
const {
|
||||
REPL
|
||||
} = await import('./screens/REPL.js');
|
||||
await renderAndRun(root, <App {...appProps}>
|
||||
<REPL {...replProps} />
|
||||
</App>);
|
||||
getFpsMetrics: () => FpsMetrics | undefined
|
||||
stats?: StatsStore
|
||||
initialState: AppState
|
||||
}
|
||||
|
||||
export async function launchRepl(
|
||||
root: Root,
|
||||
appProps: AppWrapperProps,
|
||||
replProps: REPLProps,
|
||||
renderAndRun: (root: Root, element: React.ReactNode) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const { App } = await import('./components/App.js')
|
||||
const { REPL } = await import('./screens/REPL.js')
|
||||
await renderAndRun(
|
||||
root,
|
||||
<App {...appProps}>
|
||||
<REPL {...replProps} />
|
||||
</App>,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,96 +1,72 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import * as React from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { KeyboardShortcutHint } from '../components/design-system/KeyboardShortcutHint.js';
|
||||
import { Box, Text } from '../ink.js';
|
||||
import { useKeybinding } from '../keybindings/useKeybinding.js';
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { KeyboardShortcutHint } from '../components/design-system/KeyboardShortcutHint.js'
|
||||
import { Box, Text } from '../ink.js'
|
||||
import { useKeybinding } from '../keybindings/useKeybinding.js'
|
||||
|
||||
type Props = {
|
||||
onRun: () => void;
|
||||
onCancel: () => void;
|
||||
reason: string;
|
||||
};
|
||||
onRun: () => void
|
||||
onCancel: () => void
|
||||
reason: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that shows a notification about running /issue command
|
||||
* with the ability to cancel via ESC key
|
||||
*/
|
||||
export function AutoRunIssueNotification(t0) {
|
||||
const $ = _c(8);
|
||||
const {
|
||||
onRun,
|
||||
onCancel,
|
||||
reason
|
||||
} = t0;
|
||||
const hasRunRef = useRef(false);
|
||||
let t1;
|
||||
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t1 = {
|
||||
context: "Confirmation"
|
||||
};
|
||||
$[0] = t1;
|
||||
} else {
|
||||
t1 = $[0];
|
||||
}
|
||||
useKeybinding("confirm:no", onCancel, t1);
|
||||
let t2;
|
||||
let t3;
|
||||
if ($[1] !== onRun) {
|
||||
t2 = () => {
|
||||
if (!hasRunRef.current) {
|
||||
hasRunRef.current = true;
|
||||
onRun();
|
||||
}
|
||||
};
|
||||
t3 = [onRun];
|
||||
$[1] = onRun;
|
||||
$[2] = t2;
|
||||
$[3] = t3;
|
||||
} else {
|
||||
t2 = $[2];
|
||||
t3 = $[3];
|
||||
}
|
||||
useEffect(t2, t3);
|
||||
let t4;
|
||||
if ($[4] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t4 = <Box><Text bold={true}>Running feedback capture...</Text></Box>;
|
||||
$[4] = t4;
|
||||
} else {
|
||||
t4 = $[4];
|
||||
}
|
||||
let t5;
|
||||
if ($[5] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t5 = <Box><Text dimColor={true}>Press <KeyboardShortcutHint shortcut="Esc" action="cancel" /> anytime</Text></Box>;
|
||||
$[5] = t5;
|
||||
} else {
|
||||
t5 = $[5];
|
||||
}
|
||||
let t6;
|
||||
if ($[6] !== reason) {
|
||||
t6 = <Box flexDirection="column" marginTop={1}>{t4}{t5}<Box><Text dimColor={true}>Reason: {reason}</Text></Box></Box>;
|
||||
$[6] = reason;
|
||||
$[7] = t6;
|
||||
} else {
|
||||
t6 = $[7];
|
||||
}
|
||||
return t6;
|
||||
export function AutoRunIssueNotification({
|
||||
onRun,
|
||||
onCancel,
|
||||
reason,
|
||||
}: Props): React.ReactNode {
|
||||
const hasRunRef = useRef(false)
|
||||
|
||||
// Handle ESC key to cancel
|
||||
useKeybinding('confirm:no', onCancel, { context: 'Confirmation' })
|
||||
|
||||
// Run /issue immediately on mount
|
||||
useEffect(() => {
|
||||
if (!hasRunRef.current) {
|
||||
hasRunRef.current = true
|
||||
onRun()
|
||||
}
|
||||
}, [onRun])
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text bold>Running feedback capture...</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
Press <KeyboardShortcutHint shortcut="Esc" action="cancel" /> anytime
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text dimColor>Reason: {reason}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
export type AutoRunIssueReason = 'feedback_survey_bad' | 'feedback_survey_good';
|
||||
|
||||
export type AutoRunIssueReason = 'feedback_survey_bad' | 'feedback_survey_good'
|
||||
|
||||
/**
|
||||
* Determines if /issue should auto-run for Ant users
|
||||
*/
|
||||
export function shouldAutoRunIssue(reason: AutoRunIssueReason): boolean {
|
||||
// Only for Ant users
|
||||
if ((process.env.USER_TYPE) !== 'ant') {
|
||||
return false;
|
||||
if (process.env.USER_TYPE !== 'ant') {
|
||||
return false
|
||||
}
|
||||
|
||||
switch (reason) {
|
||||
case 'feedback_survey_bad':
|
||||
return false;
|
||||
return false
|
||||
case 'feedback_survey_good':
|
||||
return false;
|
||||
return false
|
||||
default:
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,10 +76,10 @@ export function shouldAutoRunIssue(reason: AutoRunIssueReason): boolean {
|
||||
*/
|
||||
export function getAutoRunCommand(reason: AutoRunIssueReason): string {
|
||||
// Only ant builds have the /good-claude command
|
||||
if ((process.env.USER_TYPE) === 'ant' && reason === 'feedback_survey_good') {
|
||||
return '/good-claude';
|
||||
if (process.env.USER_TYPE === 'ant' && reason === 'feedback_survey_good') {
|
||||
return '/good-claude'
|
||||
}
|
||||
return '/issue';
|
||||
return '/issue'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,10 +88,10 @@ export function getAutoRunCommand(reason: AutoRunIssueReason): string {
|
||||
export function getAutoRunIssueReasonText(reason: AutoRunIssueReason): string {
|
||||
switch (reason) {
|
||||
case 'feedback_survey_bad':
|
||||
return 'You responded "Bad" to the feedback survey';
|
||||
return 'You responded "Bad" to the feedback survey'
|
||||
case 'feedback_survey_good':
|
||||
return 'You responded "Good" to the feedback survey';
|
||||
return 'You responded "Good" to the feedback survey'
|
||||
default:
|
||||
return 'Unknown reason';
|
||||
return 'Unknown reason'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,104 +1,146 @@
|
||||
import * as React from 'react';
|
||||
import { MessageResponse } from '../../components/MessageResponse.js';
|
||||
import { supportsHyperlinks } from '../../ink/supports-hyperlinks.js';
|
||||
import { Link, Text } from '../../ink.js';
|
||||
import { renderToolResultMessage as renderDefaultMCPToolResultMessage } from '../../tools/MCPTool/UI.js';
|
||||
import type { MCPToolResult } from '../../utils/mcpValidation.js';
|
||||
import { truncateToWidth } from '../format.js';
|
||||
import { trackClaudeInChromeTabId } from './common.js';
|
||||
export type { Tool } from '@modelcontextprotocol/sdk/types.js';
|
||||
import * as React from 'react'
|
||||
import { MessageResponse } from '../../components/MessageResponse.js'
|
||||
import { supportsHyperlinks } from '../../ink/supports-hyperlinks.js'
|
||||
import { Link, Text } from '../../ink.js'
|
||||
import { renderToolResultMessage as renderDefaultMCPToolResultMessage } from '../../tools/MCPTool/UI.js'
|
||||
import type { MCPToolResult } from '../../utils/mcpValidation.js'
|
||||
import { truncateToWidth } from '../format.js'
|
||||
import { trackClaudeInChromeTabId } from './common.js'
|
||||
|
||||
export type { Tool } from '@modelcontextprotocol/sdk/types.js'
|
||||
|
||||
/**
|
||||
* All tool names from BROWSER_TOOLS in @ant/claude-for-chrome-mcp.
|
||||
* Keep in sync with the package's BROWSER_TOOLS array.
|
||||
*/
|
||||
export type ChromeToolName = 'javascript_tool' | 'read_page' | 'find' | 'form_input' | 'computer' | 'navigate' | 'resize_window' | 'gif_creator' | 'upload_image' | 'get_page_text' | 'tabs_context_mcp' | 'tabs_create_mcp' | 'update_plan' | 'read_console_messages' | 'read_network_requests' | 'shortcuts_list' | 'shortcuts_execute';
|
||||
const CHROME_EXTENSION_FOCUS_TAB_URL_BASE = 'https://clau.de/chrome/tab/';
|
||||
function renderChromeToolUseMessage(input: Record<string, unknown>, toolName: ChromeToolName, verbose: boolean): React.ReactNode {
|
||||
const tabId = input.tabId;
|
||||
export type ChromeToolName =
|
||||
| 'javascript_tool'
|
||||
| 'read_page'
|
||||
| 'find'
|
||||
| 'form_input'
|
||||
| 'computer'
|
||||
| 'navigate'
|
||||
| 'resize_window'
|
||||
| 'gif_creator'
|
||||
| 'upload_image'
|
||||
| 'get_page_text'
|
||||
| 'tabs_context_mcp'
|
||||
| 'tabs_create_mcp'
|
||||
| 'update_plan'
|
||||
| 'read_console_messages'
|
||||
| 'read_network_requests'
|
||||
| 'shortcuts_list'
|
||||
| 'shortcuts_execute'
|
||||
|
||||
const CHROME_EXTENSION_FOCUS_TAB_URL_BASE = 'https://clau.de/chrome/tab/'
|
||||
|
||||
function renderChromeToolUseMessage(
|
||||
input: Record<string, unknown>,
|
||||
toolName: ChromeToolName,
|
||||
verbose: boolean,
|
||||
): React.ReactNode {
|
||||
const tabId = input.tabId
|
||||
if (typeof tabId === 'number') {
|
||||
trackClaudeInChromeTabId(tabId);
|
||||
trackClaudeInChromeTabId(tabId)
|
||||
}
|
||||
|
||||
// Build secondary info based on tool type and input
|
||||
const secondaryInfo: string[] = [];
|
||||
const secondaryInfo: string[] = []
|
||||
|
||||
switch (toolName) {
|
||||
case 'navigate':
|
||||
if (typeof input.url === 'string') {
|
||||
try {
|
||||
const url = new URL(input.url);
|
||||
secondaryInfo.push(url.hostname);
|
||||
const url = new URL(input.url)
|
||||
secondaryInfo.push(url.hostname)
|
||||
} catch {
|
||||
secondaryInfo.push(truncateToWidth(input.url, 30));
|
||||
secondaryInfo.push(truncateToWidth(input.url, 30))
|
||||
}
|
||||
}
|
||||
break;
|
||||
break
|
||||
|
||||
case 'find':
|
||||
if (typeof input.query === 'string') {
|
||||
secondaryInfo.push(`pattern: ${truncateToWidth(input.query, 30)}`);
|
||||
secondaryInfo.push(`pattern: ${truncateToWidth(input.query, 30)}`)
|
||||
}
|
||||
break;
|
||||
break
|
||||
|
||||
case 'computer':
|
||||
if (typeof input.action === 'string') {
|
||||
const action = input.action;
|
||||
if (action === 'left_click' || action === 'right_click' || action === 'double_click' || action === 'middle_click') {
|
||||
const action = input.action
|
||||
if (
|
||||
action === 'left_click' ||
|
||||
action === 'right_click' ||
|
||||
action === 'double_click' ||
|
||||
action === 'middle_click'
|
||||
) {
|
||||
if (typeof input.ref === 'string') {
|
||||
secondaryInfo.push(`${action} on ${input.ref}`);
|
||||
secondaryInfo.push(`${action} on ${input.ref}`)
|
||||
} else if (Array.isArray(input.coordinate)) {
|
||||
secondaryInfo.push(`${action} at (${input.coordinate.join(', ')})`);
|
||||
secondaryInfo.push(`${action} at (${input.coordinate.join(', ')})`)
|
||||
} else {
|
||||
secondaryInfo.push(action);
|
||||
secondaryInfo.push(action)
|
||||
}
|
||||
} else if (action === 'type' && typeof input.text === 'string') {
|
||||
secondaryInfo.push(`type "${truncateToWidth(input.text, 15)}"`);
|
||||
secondaryInfo.push(`type "${truncateToWidth(input.text, 15)}"`)
|
||||
} else if (action === 'key' && typeof input.text === 'string') {
|
||||
secondaryInfo.push(`key ${input.text}`);
|
||||
} else if (action === 'scroll' && typeof input.scroll_direction === 'string') {
|
||||
secondaryInfo.push(`scroll ${input.scroll_direction}`);
|
||||
secondaryInfo.push(`key ${input.text}`)
|
||||
} else if (
|
||||
action === 'scroll' &&
|
||||
typeof input.scroll_direction === 'string'
|
||||
) {
|
||||
secondaryInfo.push(`scroll ${input.scroll_direction}`)
|
||||
} else if (action === 'wait' && typeof input.duration === 'number') {
|
||||
secondaryInfo.push(`wait ${input.duration}s`);
|
||||
secondaryInfo.push(`wait ${input.duration}s`)
|
||||
} else if (action === 'left_click_drag') {
|
||||
secondaryInfo.push('drag');
|
||||
secondaryInfo.push('drag')
|
||||
} else {
|
||||
secondaryInfo.push(action);
|
||||
secondaryInfo.push(action)
|
||||
}
|
||||
}
|
||||
break;
|
||||
break
|
||||
|
||||
case 'gif_creator':
|
||||
if (typeof input.action === 'string') {
|
||||
secondaryInfo.push(`${input.action}`);
|
||||
secondaryInfo.push(`${input.action}`)
|
||||
}
|
||||
break;
|
||||
break
|
||||
|
||||
case 'resize_window':
|
||||
if (typeof input.width === 'number' && typeof input.height === 'number') {
|
||||
secondaryInfo.push(`${input.width}x${input.height}`);
|
||||
secondaryInfo.push(`${input.width}x${input.height}`)
|
||||
}
|
||||
break;
|
||||
break
|
||||
|
||||
case 'read_console_messages':
|
||||
if (typeof input.pattern === 'string') {
|
||||
secondaryInfo.push(`pattern: ${truncateToWidth(input.pattern, 20)}`);
|
||||
secondaryInfo.push(`pattern: ${truncateToWidth(input.pattern, 20)}`)
|
||||
}
|
||||
if (input.onlyErrors === true) {
|
||||
secondaryInfo.push('errors only');
|
||||
secondaryInfo.push('errors only')
|
||||
}
|
||||
break;
|
||||
break
|
||||
|
||||
case 'read_network_requests':
|
||||
if (typeof input.urlPattern === 'string') {
|
||||
secondaryInfo.push(`pattern: ${truncateToWidth(input.urlPattern, 20)}`);
|
||||
secondaryInfo.push(`pattern: ${truncateToWidth(input.urlPattern, 20)}`)
|
||||
}
|
||||
break;
|
||||
break
|
||||
|
||||
case 'shortcuts_execute':
|
||||
if (typeof input.shortcutId === 'string') {
|
||||
secondaryInfo.push(`shortcut_id: ${input.shortcutId}`);
|
||||
secondaryInfo.push(`shortcut_id: ${input.shortcutId}`)
|
||||
}
|
||||
break;
|
||||
break
|
||||
|
||||
case 'javascript_tool':
|
||||
// In verbose mode, show the full code
|
||||
if (verbose && typeof input.text === 'string') {
|
||||
return input.text;
|
||||
return input.text
|
||||
}
|
||||
// In non-verbose mode, return empty string to preserve View Tab layout
|
||||
return '';
|
||||
return ''
|
||||
|
||||
case 'tabs_create_mcp':
|
||||
case 'tabs_context_mcp':
|
||||
case 'form_input':
|
||||
@@ -109,9 +151,10 @@ function renderChromeToolUseMessage(input: Record<string, unknown>, toolName: Ch
|
||||
case 'update_plan':
|
||||
// These tools don't have meaningful secondary info to show inline.
|
||||
// Return empty string (not null) to ensure tool header still renders.
|
||||
return '';
|
||||
return ''
|
||||
}
|
||||
return secondaryInfo.join(', ') || null;
|
||||
|
||||
return secondaryInfo.join(', ') || null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,22 +166,29 @@ function renderChromeToolUseMessage(input: Record<string, unknown>, toolName: Ch
|
||||
*/
|
||||
function renderChromeViewTabLink(input: unknown): React.ReactNode {
|
||||
if (!supportsHyperlinks()) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
if (typeof input !== 'object' || input === null || !('tabId' in input)) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
const tabId = typeof input.tabId === 'number' ? input.tabId : typeof input.tabId === 'string' ? parseInt(input.tabId, 10) : NaN;
|
||||
const tabId =
|
||||
typeof input.tabId === 'number'
|
||||
? input.tabId
|
||||
: typeof input.tabId === 'string'
|
||||
? parseInt(input.tabId, 10)
|
||||
: NaN
|
||||
if (isNaN(tabId)) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
const linkUrl = `${CHROME_EXTENSION_FOCUS_TAB_URL_BASE}${tabId}`;
|
||||
return <Text>
|
||||
const linkUrl = `${CHROME_EXTENSION_FOCUS_TAB_URL_BASE}${tabId}`
|
||||
return (
|
||||
<Text>
|
||||
{' '}
|
||||
<Link url={linkUrl}>
|
||||
<Text color="subtle">[View Tab]</Text>
|
||||
</Link>
|
||||
</Text>;
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,72 +196,79 @@ function renderChromeViewTabLink(input: unknown): React.ReactNode {
|
||||
* Shows a brief summary for successful results. Errors are handled by
|
||||
* the default renderToolUseErrorMessage when is_error is set.
|
||||
*/
|
||||
export function renderChromeToolResultMessage(output: MCPToolResult, toolName: ChromeToolName, verbose: boolean): React.ReactNode {
|
||||
export function renderChromeToolResultMessage(
|
||||
output: MCPToolResult,
|
||||
toolName: ChromeToolName,
|
||||
verbose: boolean,
|
||||
): React.ReactNode {
|
||||
if (verbose) {
|
||||
return renderDefaultMCPToolResultMessage(output, [], {
|
||||
verbose
|
||||
});
|
||||
return renderDefaultMCPToolResultMessage(output, [], { verbose })
|
||||
}
|
||||
let summary: string | null = null;
|
||||
|
||||
let summary: string | null = null
|
||||
switch (toolName) {
|
||||
case 'navigate':
|
||||
summary = 'Navigation completed';
|
||||
break;
|
||||
summary = 'Navigation completed'
|
||||
break
|
||||
case 'tabs_create_mcp':
|
||||
summary = 'Tab created';
|
||||
break;
|
||||
summary = 'Tab created'
|
||||
break
|
||||
case 'tabs_context_mcp':
|
||||
summary = 'Tabs read';
|
||||
break;
|
||||
summary = 'Tabs read'
|
||||
break
|
||||
case 'form_input':
|
||||
summary = 'Input completed';
|
||||
break;
|
||||
summary = 'Input completed'
|
||||
break
|
||||
case 'computer':
|
||||
summary = 'Action completed';
|
||||
break;
|
||||
summary = 'Action completed'
|
||||
break
|
||||
case 'resize_window':
|
||||
summary = 'Window resized';
|
||||
break;
|
||||
summary = 'Window resized'
|
||||
break
|
||||
case 'find':
|
||||
summary = 'Search completed';
|
||||
break;
|
||||
summary = 'Search completed'
|
||||
break
|
||||
case 'gif_creator':
|
||||
summary = 'GIF action completed';
|
||||
break;
|
||||
summary = 'GIF action completed'
|
||||
break
|
||||
case 'read_console_messages':
|
||||
summary = 'Console messages retrieved';
|
||||
break;
|
||||
summary = 'Console messages retrieved'
|
||||
break
|
||||
case 'read_network_requests':
|
||||
summary = 'Network requests retrieved';
|
||||
break;
|
||||
summary = 'Network requests retrieved'
|
||||
break
|
||||
case 'shortcuts_list':
|
||||
summary = 'Shortcuts retrieved';
|
||||
break;
|
||||
summary = 'Shortcuts retrieved'
|
||||
break
|
||||
case 'shortcuts_execute':
|
||||
summary = 'Shortcut executed';
|
||||
break;
|
||||
summary = 'Shortcut executed'
|
||||
break
|
||||
case 'javascript_tool':
|
||||
summary = 'Script executed';
|
||||
break;
|
||||
summary = 'Script executed'
|
||||
break
|
||||
case 'read_page':
|
||||
summary = 'Page read';
|
||||
break;
|
||||
summary = 'Page read'
|
||||
break
|
||||
case 'upload_image':
|
||||
summary = 'Image uploaded';
|
||||
break;
|
||||
summary = 'Image uploaded'
|
||||
break
|
||||
case 'get_page_text':
|
||||
summary = 'Page text retrieved';
|
||||
break;
|
||||
summary = 'Page text retrieved'
|
||||
break
|
||||
case 'update_plan':
|
||||
summary = 'Plan updated';
|
||||
break;
|
||||
summary = 'Plan updated'
|
||||
break
|
||||
}
|
||||
|
||||
if (summary) {
|
||||
return <MessageResponse height={1}>
|
||||
return (
|
||||
<MessageResponse height={1}>
|
||||
<Text dimColor>{summary}</Text>
|
||||
</MessageResponse>;
|
||||
</MessageResponse>
|
||||
)
|
||||
}
|
||||
return null;
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,43 +276,56 @@ export function renderChromeToolResultMessage(output: MCPToolResult, toolName: C
|
||||
* rendering for chrome tools in a single spread operation.
|
||||
*/
|
||||
export function getClaudeInChromeMCPToolOverrides(toolName: string): {
|
||||
userFacingName: (input?: Record<string, unknown>) => string;
|
||||
renderToolUseMessage: (input: Record<string, unknown>, options: {
|
||||
verbose: boolean;
|
||||
}) => React.ReactNode;
|
||||
renderToolUseTag: (input: Partial<Record<string, unknown>>) => React.ReactNode;
|
||||
renderToolResultMessage: (output: string | MCPToolResult, progressMessagesForMessage: unknown[], options: {
|
||||
verbose: boolean;
|
||||
}) => React.ReactNode;
|
||||
userFacingName: (input?: Record<string, unknown>) => string
|
||||
renderToolUseMessage: (
|
||||
input: Record<string, unknown>,
|
||||
options: { verbose: boolean },
|
||||
) => React.ReactNode
|
||||
renderToolUseTag: (input: Partial<Record<string, unknown>>) => React.ReactNode
|
||||
renderToolResultMessage: (
|
||||
output: string | MCPToolResult,
|
||||
progressMessagesForMessage: unknown[],
|
||||
options: { verbose: boolean },
|
||||
) => React.ReactNode
|
||||
} {
|
||||
return {
|
||||
userFacingName(_input?: Record<string, unknown>) {
|
||||
// Trim the _mcp postfix that show up in some of the tool names
|
||||
const displayName = toolName.replace(/_mcp$/, '');
|
||||
return `Claude in Chrome[${displayName}]`;
|
||||
const displayName = toolName.replace(/_mcp$/, '')
|
||||
return `Claude in Chrome[${displayName}]`
|
||||
},
|
||||
renderToolUseMessage(input: Record<string, unknown>, {
|
||||
verbose
|
||||
}: {
|
||||
verbose: boolean;
|
||||
}): React.ReactNode {
|
||||
return renderChromeToolUseMessage(input, toolName as ChromeToolName, verbose);
|
||||
renderToolUseMessage(
|
||||
input: Record<string, unknown>,
|
||||
{ verbose }: { verbose: boolean },
|
||||
): React.ReactNode {
|
||||
return renderChromeToolUseMessage(
|
||||
input,
|
||||
toolName as ChromeToolName,
|
||||
verbose,
|
||||
)
|
||||
},
|
||||
renderToolUseTag(input: Partial<Record<string, unknown>>): React.ReactNode {
|
||||
return renderChromeViewTabLink(input);
|
||||
return renderChromeViewTabLink(input)
|
||||
},
|
||||
renderToolResultMessage(output: string | MCPToolResult, _progressMessagesForMessage: unknown[], {
|
||||
verbose
|
||||
}: {
|
||||
verbose: boolean;
|
||||
}): React.ReactNode {
|
||||
renderToolResultMessage(
|
||||
output: string | MCPToolResult,
|
||||
_progressMessagesForMessage: unknown[],
|
||||
{ verbose }: { verbose: boolean },
|
||||
): React.ReactNode {
|
||||
if (!isMCPToolResult(output)) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
return renderChromeToolResultMessage(output, toolName as ChromeToolName, verbose);
|
||||
}
|
||||
};
|
||||
return renderChromeToolResultMessage(
|
||||
output,
|
||||
toolName as ChromeToolName,
|
||||
verbose,
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
function isMCPToolResult(output: string | MCPToolResult): output is MCPToolResult {
|
||||
return typeof output === 'object' && output !== null;
|
||||
|
||||
function isMCPToolResult(
|
||||
output: string | MCPToolResult,
|
||||
): output is MCPToolResult {
|
||||
return typeof output === 'object' && output !== null
|
||||
}
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import { MessageResponse } from '../../components/MessageResponse.js';
|
||||
import { Text } from '../../ink.js';
|
||||
import { truncateToWidth } from '../format.js';
|
||||
import type { MCPToolResult } from '../mcpValidation.js';
|
||||
import * as React from 'react'
|
||||
import { MessageResponse } from '../../components/MessageResponse.js'
|
||||
import { Text } from '../../ink.js'
|
||||
import { truncateToWidth } from '../format.js'
|
||||
import type { MCPToolResult } from '../mcpValidation.js'
|
||||
|
||||
type CuToolInput = Record<string, unknown> & {
|
||||
coordinate?: [number, number];
|
||||
start_coordinate?: [number, number];
|
||||
text?: string;
|
||||
apps?: Array<{
|
||||
displayName?: string;
|
||||
}>;
|
||||
region?: [number, number, number, number];
|
||||
direction?: string;
|
||||
amount?: number;
|
||||
duration?: number;
|
||||
};
|
||||
function fmtCoord(c: [number, number] | undefined): string {
|
||||
return c ? `(${c[0]}, ${c[1]})` : '';
|
||||
coordinate?: [number, number]
|
||||
start_coordinate?: [number, number]
|
||||
text?: string
|
||||
apps?: Array<{ displayName?: string }>
|
||||
region?: [number, number, number, number]
|
||||
direction?: string
|
||||
amount?: number
|
||||
duration?: number
|
||||
}
|
||||
|
||||
function fmtCoord(c: [number, number] | undefined): string {
|
||||
return c ? `(${c[0]}, ${c[1]})` : ''
|
||||
}
|
||||
|
||||
const RESULT_SUMMARY: Readonly<Partial<Record<string, string>>> = {
|
||||
screenshot: 'Captured',
|
||||
zoom: 'Captured',
|
||||
@@ -32,8 +33,8 @@ const RESULT_SUMMARY: Readonly<Partial<Record<string, string>>> = {
|
||||
hold_key: 'Pressed',
|
||||
scroll: 'Scrolled',
|
||||
left_click_drag: 'Dragged',
|
||||
open_application: 'Opened'
|
||||
};
|
||||
open_application: 'Opened',
|
||||
}
|
||||
|
||||
/**
|
||||
* Rendering overrides for `mcp__computer-use__*` tools. Spread into the MCP
|
||||
@@ -41,18 +42,22 @@ const RESULT_SUMMARY: Readonly<Partial<Record<string, string>>> = {
|
||||
* Mirror of `getClaudeInChromeMCPToolOverrides`.
|
||||
*/
|
||||
export function getComputerUseMCPRenderingOverrides(toolName: string): {
|
||||
userFacingName: () => string;
|
||||
renderToolUseMessage: (input: Record<string, unknown>, options: {
|
||||
verbose: boolean;
|
||||
}) => React.ReactNode;
|
||||
renderToolResultMessage: (output: MCPToolResult, progressMessages: unknown[], options: {
|
||||
verbose: boolean;
|
||||
}) => React.ReactNode;
|
||||
userFacingName: () => string
|
||||
renderToolUseMessage: (
|
||||
input: Record<string, unknown>,
|
||||
options: { verbose: boolean },
|
||||
) => React.ReactNode
|
||||
renderToolResultMessage: (
|
||||
output: MCPToolResult,
|
||||
progressMessages: unknown[],
|
||||
options: { verbose: boolean },
|
||||
) => React.ReactNode
|
||||
} {
|
||||
return {
|
||||
userFacingName() {
|
||||
return `Computer Use[${toolName}]`;
|
||||
return `Computer Use[${toolName}]`
|
||||
},
|
||||
|
||||
// AssistantToolUseMessage.tsx contract: null hides the ENTIRE row, '' shows
|
||||
// the tool name without "(args)". Every path below returns '' when there's
|
||||
// nothing to show — never null.
|
||||
@@ -64,61 +69,89 @@ export function getComputerUseMCPRenderingOverrides(toolName: string): {
|
||||
case 'cursor_position':
|
||||
case 'list_granted_applications':
|
||||
case 'read_clipboard':
|
||||
return '';
|
||||
return ''
|
||||
|
||||
case 'left_click':
|
||||
case 'right_click':
|
||||
case 'middle_click':
|
||||
case 'double_click':
|
||||
case 'triple_click':
|
||||
case 'mouse_move':
|
||||
return fmtCoord(input.coordinate);
|
||||
return fmtCoord(input.coordinate)
|
||||
|
||||
case 'left_click_drag':
|
||||
return input.start_coordinate ? `${fmtCoord(input.start_coordinate)} → ${fmtCoord(input.coordinate)}` : `to ${fmtCoord(input.coordinate)}`;
|
||||
return input.start_coordinate
|
||||
? `${fmtCoord(input.start_coordinate)} → ${fmtCoord(input.coordinate)}`
|
||||
: `to ${fmtCoord(input.coordinate)}`
|
||||
|
||||
case 'type':
|
||||
return typeof input.text === 'string' ? `"${truncateToWidth(input.text, 40)}"` : '';
|
||||
return typeof input.text === 'string'
|
||||
? `"${truncateToWidth(input.text, 40)}"`
|
||||
: ''
|
||||
|
||||
case 'key':
|
||||
case 'hold_key':
|
||||
return typeof input.text === 'string' ? input.text : '';
|
||||
return typeof input.text === 'string' ? input.text : ''
|
||||
|
||||
case 'scroll':
|
||||
return [input.direction, input.amount && `×${input.amount}`, input.coordinate && `at ${fmtCoord(input.coordinate)}`].filter(Boolean).join(' ');
|
||||
case 'zoom':
|
||||
{
|
||||
const r = input.region;
|
||||
return Array.isArray(r) && r.length === 4 ? `[${r[0]}, ${r[1]}, ${r[2]}, ${r[3]}]` : '';
|
||||
}
|
||||
return [
|
||||
input.direction,
|
||||
input.amount && `×${input.amount}`,
|
||||
input.coordinate && `at ${fmtCoord(input.coordinate)}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
case 'zoom': {
|
||||
const r = input.region
|
||||
return Array.isArray(r) && r.length === 4
|
||||
? `[${r[0]}, ${r[1]}, ${r[2]}, ${r[3]}]`
|
||||
: ''
|
||||
}
|
||||
|
||||
case 'wait':
|
||||
return typeof input.duration === 'number' ? `${input.duration}s` : '';
|
||||
return typeof input.duration === 'number' ? `${input.duration}s` : ''
|
||||
|
||||
case 'write_clipboard':
|
||||
return typeof input.text === 'string' ? `"${truncateToWidth(input.text, 40)}"` : '';
|
||||
return typeof input.text === 'string'
|
||||
? `"${truncateToWidth(input.text, 40)}"`
|
||||
: ''
|
||||
|
||||
case 'open_application':
|
||||
return typeof input.bundle_id === 'string' ? String(input.bundle_id) : '';
|
||||
case 'request_access':
|
||||
{
|
||||
const apps = input.apps;
|
||||
if (!Array.isArray(apps)) return '';
|
||||
const names = apps.map(a => typeof a?.displayName === 'string' ? a.displayName : '').filter(Boolean);
|
||||
return names.join(', ');
|
||||
}
|
||||
case 'computer_batch':
|
||||
{
|
||||
const actions = input.actions;
|
||||
return Array.isArray(actions) ? `${actions.length} actions` : '';
|
||||
}
|
||||
return typeof input.bundle_id === 'string'
|
||||
? String(input.bundle_id)
|
||||
: ''
|
||||
|
||||
case 'request_access': {
|
||||
const apps = input.apps
|
||||
if (!Array.isArray(apps)) return ''
|
||||
const names = apps
|
||||
.map(a => (typeof a?.displayName === 'string' ? a.displayName : ''))
|
||||
.filter(Boolean)
|
||||
return names.join(', ')
|
||||
}
|
||||
|
||||
case 'computer_batch': {
|
||||
const actions = input.actions
|
||||
return Array.isArray(actions) ? `${actions.length} actions` : ''
|
||||
}
|
||||
|
||||
default:
|
||||
return '';
|
||||
return ''
|
||||
}
|
||||
},
|
||||
renderToolResultMessage(output, _progress, {
|
||||
verbose
|
||||
}) {
|
||||
if (verbose || typeof output !== 'object' || output === null) return null;
|
||||
|
||||
renderToolResultMessage(output, _progress, { verbose }) {
|
||||
if (verbose || typeof output !== 'object' || output === null) return null
|
||||
|
||||
// Non-verbose: one-line dim summary, like Chrome's pattern.
|
||||
const summary = RESULT_SUMMARY[toolName];
|
||||
if (!summary) return null;
|
||||
return <MessageResponse height={1}>
|
||||
const summary = RESULT_SUMMARY[toolName]
|
||||
if (!summary) return null
|
||||
return (
|
||||
<MessageResponse height={1}>
|
||||
<Text dimColor>{summary}</Text>
|
||||
</MessageResponse>;
|
||||
}
|
||||
};
|
||||
</MessageResponse>
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,22 +16,35 @@
|
||||
* GrowthBook gate `tengu_malort_pedway` (see gates.ts).
|
||||
*/
|
||||
|
||||
import { bindSessionContext, type ComputerUseSessionContext, type CuCallToolResult, type CuPermissionRequest, type CuPermissionResponse, DEFAULT_GRANT_FLAGS, type ScreenshotDims } from '@ant/computer-use-mcp';
|
||||
import * as React from 'react';
|
||||
import { getSessionId } from '../../bootstrap/state.js';
|
||||
import { ComputerUseApproval } from '../../components/permissions/ComputerUseApproval/ComputerUseApproval.js';
|
||||
import type { Tool, ToolUseContext } from '../../Tool.js';
|
||||
import { logForDebugging } from '../debug.js';
|
||||
import { checkComputerUseLock, tryAcquireComputerUseLock } from './computerUseLock.js';
|
||||
import { registerEscHotkey } from './escHotkey.js';
|
||||
import { getChicagoCoordinateMode } from './gates.js';
|
||||
import { getComputerUseHostAdapter } from './hostAdapter.js';
|
||||
import { getComputerUseMCPRenderingOverrides } from './toolRendering.js';
|
||||
type CallOverride = Pick<Tool, 'call'>['call'];
|
||||
import {
|
||||
bindSessionContext,
|
||||
type ComputerUseSessionContext,
|
||||
type CuCallToolResult,
|
||||
type CuPermissionRequest,
|
||||
type CuPermissionResponse,
|
||||
DEFAULT_GRANT_FLAGS,
|
||||
type ScreenshotDims,
|
||||
} from '@ant/computer-use-mcp'
|
||||
import * as React from 'react'
|
||||
import { getSessionId } from '../../bootstrap/state.js'
|
||||
import { ComputerUseApproval } from '../../components/permissions/ComputerUseApproval/ComputerUseApproval.js'
|
||||
import type { Tool, ToolUseContext } from '../../Tool.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import {
|
||||
checkComputerUseLock,
|
||||
tryAcquireComputerUseLock,
|
||||
} from './computerUseLock.js'
|
||||
import { registerEscHotkey } from './escHotkey.js'
|
||||
import { getChicagoCoordinateMode } from './gates.js'
|
||||
import { getComputerUseHostAdapter } from './hostAdapter.js'
|
||||
import { getComputerUseMCPRenderingOverrides } from './toolRendering.js'
|
||||
|
||||
type CallOverride = Pick<Tool, 'call'>['call']
|
||||
|
||||
type Binding = {
|
||||
ctx: ComputerUseSessionContext;
|
||||
dispatch: (name: string, args: unknown) => Promise<CuCallToolResult>;
|
||||
};
|
||||
ctx: ComputerUseSessionContext
|
||||
dispatch: (name: string, args: unknown) => Promise<CuCallToolResult>
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached binding — built on first `.call()`, reused for process lifetime.
|
||||
@@ -46,35 +59,47 @@ type Binding = {
|
||||
* its internal screenshot blob survives, but `ToolUseContext` is per-call.
|
||||
* Tests will need to either inject the cache or run serially.
|
||||
*/
|
||||
let binding: Binding | undefined;
|
||||
let currentToolUseContext: ToolUseContext | undefined;
|
||||
let binding: Binding | undefined
|
||||
let currentToolUseContext: ToolUseContext | undefined
|
||||
|
||||
function tuc(): ToolUseContext {
|
||||
// Safe: `binding` is only populated when `currentToolUseContext` is set.
|
||||
// Called only from within `ctx` callbacks, which only fire during dispatch.
|
||||
return currentToolUseContext!;
|
||||
return currentToolUseContext!
|
||||
}
|
||||
|
||||
function formatLockHeld(holder: string): string {
|
||||
return `Computer use is in use by another Claude session (${holder.slice(0, 8)}…). Wait for that session to finish or run /exit there.`;
|
||||
return `Computer use is in use by another Claude session (${holder.slice(0, 8)}…). Wait for that session to finish or run /exit there.`
|
||||
}
|
||||
|
||||
export function buildSessionContext(): ComputerUseSessionContext {
|
||||
return {
|
||||
// ── Read state fresh via the per-call ref ─────────────────────────────
|
||||
getAllowedApps: () => tuc().getAppState().computerUseMcpState?.allowedApps ?? [],
|
||||
getGrantFlags: () => tuc().getAppState().computerUseMcpState?.grantFlags ?? DEFAULT_GRANT_FLAGS,
|
||||
getAllowedApps: () =>
|
||||
tuc().getAppState().computerUseMcpState?.allowedApps ?? [],
|
||||
getGrantFlags: () =>
|
||||
tuc().getAppState().computerUseMcpState?.grantFlags ??
|
||||
DEFAULT_GRANT_FLAGS,
|
||||
// cc-2 has no Settings page for user-denied apps yet.
|
||||
getUserDeniedBundleIds: () => [],
|
||||
getSelectedDisplayId: () => tuc().getAppState().computerUseMcpState?.selectedDisplayId,
|
||||
getDisplayPinnedByModel: () => tuc().getAppState().computerUseMcpState?.displayPinnedByModel ?? false,
|
||||
getDisplayResolvedForApps: () => tuc().getAppState().computerUseMcpState?.displayResolvedForApps,
|
||||
getSelectedDisplayId: () =>
|
||||
tuc().getAppState().computerUseMcpState?.selectedDisplayId,
|
||||
getDisplayPinnedByModel: () =>
|
||||
tuc().getAppState().computerUseMcpState?.displayPinnedByModel ?? false,
|
||||
getDisplayResolvedForApps: () =>
|
||||
tuc().getAppState().computerUseMcpState?.displayResolvedForApps,
|
||||
getLastScreenshotDims: (): ScreenshotDims | undefined => {
|
||||
const d = tuc().getAppState().computerUseMcpState?.lastScreenshotDims;
|
||||
return d ? {
|
||||
...d,
|
||||
displayId: d.displayId ?? 0,
|
||||
originX: d.originX ?? 0,
|
||||
originY: d.originY ?? 0
|
||||
} : undefined;
|
||||
const d = tuc().getAppState().computerUseMcpState?.lastScreenshotDims
|
||||
return d
|
||||
? {
|
||||
...d,
|
||||
displayId: d.displayId ?? 0,
|
||||
originX: d.originX ?? 0,
|
||||
originY: d.originY ?? 0,
|
||||
}
|
||||
: undefined
|
||||
},
|
||||
|
||||
// ── Write-backs ────────────────────────────────────────────────────────
|
||||
// `setToolJSX` is guaranteed present — the gate in `main.tsx` excludes
|
||||
// non-interactive sessions. The package's `_dialogSignal` (tool-finished
|
||||
@@ -82,122 +107,143 @@ export function buildSessionContext(): ComputerUseSessionContext {
|
||||
// the dialog can't outlive it. Ctrl+C is what matters, and
|
||||
// `runPermissionDialog` wires that from the per-call ref's abortController.
|
||||
onPermissionRequest: (req, _dialogSignal) => runPermissionDialog(req),
|
||||
|
||||
// Package does the merge (dedupe + truthy-only flags). We just persist.
|
||||
onAllowedAppsChanged: (apps, flags) => tuc().setAppState(prev => {
|
||||
const cu = prev.computerUseMcpState;
|
||||
const prevApps = cu?.allowedApps;
|
||||
const prevFlags = cu?.grantFlags;
|
||||
const sameApps = prevApps?.length === apps.length && apps.every((a, i) => prevApps[i]?.bundleId === a.bundleId);
|
||||
const sameFlags = prevFlags?.clipboardRead === flags.clipboardRead && prevFlags?.clipboardWrite === flags.clipboardWrite && prevFlags?.systemKeyCombos === flags.systemKeyCombos;
|
||||
return sameApps && sameFlags ? prev : {
|
||||
...prev,
|
||||
computerUseMcpState: {
|
||||
...cu,
|
||||
allowedApps: [...apps],
|
||||
grantFlags: flags
|
||||
}
|
||||
};
|
||||
}),
|
||||
onAppsHidden: ids => {
|
||||
if (ids.length === 0) return;
|
||||
onAllowedAppsChanged: (apps, flags) =>
|
||||
tuc().setAppState(prev => {
|
||||
const cu = prev.computerUseMcpState;
|
||||
const existing = cu?.hiddenDuringTurn;
|
||||
if (existing && ids.every(id => existing.has(id))) return prev;
|
||||
const cu = prev.computerUseMcpState
|
||||
const prevApps = cu?.allowedApps
|
||||
const prevFlags = cu?.grantFlags
|
||||
const sameApps =
|
||||
prevApps?.length === apps.length &&
|
||||
apps.every((a, i) => prevApps[i]?.bundleId === a.bundleId)
|
||||
const sameFlags =
|
||||
prevFlags?.clipboardRead === flags.clipboardRead &&
|
||||
prevFlags?.clipboardWrite === flags.clipboardWrite &&
|
||||
prevFlags?.systemKeyCombos === flags.systemKeyCombos
|
||||
return sameApps && sameFlags
|
||||
? prev
|
||||
: {
|
||||
...prev,
|
||||
computerUseMcpState: {
|
||||
...cu,
|
||||
allowedApps: [...apps],
|
||||
grantFlags: flags,
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
onAppsHidden: ids => {
|
||||
if (ids.length === 0) return
|
||||
tuc().setAppState(prev => {
|
||||
const cu = prev.computerUseMcpState
|
||||
const existing = cu?.hiddenDuringTurn
|
||||
if (existing && ids.every(id => existing.has(id))) return prev
|
||||
return {
|
||||
...prev,
|
||||
computerUseMcpState: {
|
||||
...cu,
|
||||
hiddenDuringTurn: new Set([...(existing ?? []), ...ids])
|
||||
}
|
||||
};
|
||||
});
|
||||
hiddenDuringTurn: new Set([...(existing ?? []), ...ids]),
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// Resolver writeback only fires under a pin when Swift fell back to main
|
||||
// (pinned display unplugged) — the pin is semantically dead, so clear it
|
||||
// and the app-set key so the chase chain runs next time. When autoResolve
|
||||
// was true, onDisplayResolvedForApps re-sets the key in the same tick.
|
||||
onResolvedDisplayUpdated: id => tuc().setAppState(prev => {
|
||||
const cu = prev.computerUseMcpState;
|
||||
if (cu?.selectedDisplayId === id && !cu.displayPinnedByModel && cu.displayResolvedForApps === undefined) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
computerUseMcpState: {
|
||||
...cu,
|
||||
selectedDisplayId: id,
|
||||
displayPinnedByModel: false,
|
||||
displayResolvedForApps: undefined
|
||||
onResolvedDisplayUpdated: id =>
|
||||
tuc().setAppState(prev => {
|
||||
const cu = prev.computerUseMcpState
|
||||
if (
|
||||
cu?.selectedDisplayId === id &&
|
||||
!cu.displayPinnedByModel &&
|
||||
cu.displayResolvedForApps === undefined
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
};
|
||||
}),
|
||||
return {
|
||||
...prev,
|
||||
computerUseMcpState: {
|
||||
...cu,
|
||||
selectedDisplayId: id,
|
||||
displayPinnedByModel: false,
|
||||
displayResolvedForApps: undefined,
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
// switch_display(name) pins; switch_display("auto") unpins and clears the
|
||||
// app-set key so the next screenshot auto-resolves fresh.
|
||||
onDisplayPinned: id => tuc().setAppState(prev => {
|
||||
const cu = prev.computerUseMcpState;
|
||||
const pinned = id !== undefined;
|
||||
const nextResolvedFor = pinned ? cu?.displayResolvedForApps : undefined;
|
||||
if (cu?.selectedDisplayId === id && cu?.displayPinnedByModel === pinned && cu?.displayResolvedForApps === nextResolvedFor) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
computerUseMcpState: {
|
||||
...cu,
|
||||
selectedDisplayId: id,
|
||||
displayPinnedByModel: pinned,
|
||||
displayResolvedForApps: nextResolvedFor
|
||||
onDisplayPinned: id =>
|
||||
tuc().setAppState(prev => {
|
||||
const cu = prev.computerUseMcpState
|
||||
const pinned = id !== undefined
|
||||
const nextResolvedFor = pinned ? cu?.displayResolvedForApps : undefined
|
||||
if (
|
||||
cu?.selectedDisplayId === id &&
|
||||
cu?.displayPinnedByModel === pinned &&
|
||||
cu?.displayResolvedForApps === nextResolvedFor
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
};
|
||||
}),
|
||||
onDisplayResolvedForApps: key => tuc().setAppState(prev => {
|
||||
const cu = prev.computerUseMcpState;
|
||||
if (cu?.displayResolvedForApps === key) return prev;
|
||||
return {
|
||||
...prev,
|
||||
computerUseMcpState: {
|
||||
...cu,
|
||||
displayResolvedForApps: key
|
||||
return {
|
||||
...prev,
|
||||
computerUseMcpState: {
|
||||
...cu,
|
||||
selectedDisplayId: id,
|
||||
displayPinnedByModel: pinned,
|
||||
displayResolvedForApps: nextResolvedFor,
|
||||
},
|
||||
}
|
||||
};
|
||||
}),
|
||||
onScreenshotCaptured: dims => tuc().setAppState(prev => {
|
||||
const cu = prev.computerUseMcpState;
|
||||
const p = cu?.lastScreenshotDims;
|
||||
return p?.width === dims.width && p?.height === dims.height && p?.displayWidth === dims.displayWidth && p?.displayHeight === dims.displayHeight && p?.displayId === dims.displayId && p?.originX === dims.originX && p?.originY === dims.originY ? prev : {
|
||||
...prev,
|
||||
computerUseMcpState: {
|
||||
...cu,
|
||||
lastScreenshotDims: dims
|
||||
}),
|
||||
|
||||
onDisplayResolvedForApps: key =>
|
||||
tuc().setAppState(prev => {
|
||||
const cu = prev.computerUseMcpState
|
||||
if (cu?.displayResolvedForApps === key) return prev
|
||||
return {
|
||||
...prev,
|
||||
computerUseMcpState: { ...cu, displayResolvedForApps: key },
|
||||
}
|
||||
};
|
||||
}),
|
||||
}),
|
||||
|
||||
onScreenshotCaptured: dims =>
|
||||
tuc().setAppState(prev => {
|
||||
const cu = prev.computerUseMcpState
|
||||
const p = cu?.lastScreenshotDims
|
||||
return p?.width === dims.width &&
|
||||
p?.height === dims.height &&
|
||||
p?.displayWidth === dims.displayWidth &&
|
||||
p?.displayHeight === dims.displayHeight &&
|
||||
p?.displayId === dims.displayId &&
|
||||
p?.originX === dims.originX &&
|
||||
p?.originY === dims.originY
|
||||
? prev
|
||||
: {
|
||||
...prev,
|
||||
computerUseMcpState: { ...cu, lastScreenshotDims: dims },
|
||||
}
|
||||
}),
|
||||
|
||||
// ── Lock — async, direct file-lock calls ───────────────────────────────
|
||||
// No `lockHolderForGate` dance: the package's gate is async now. It
|
||||
// awaits `checkCuLock`, and on `holder: undefined` + non-deferring tool
|
||||
// awaits `acquireCuLock`. `defersLockAcquire` is the PACKAGE's set —
|
||||
// the local copy is gone.
|
||||
checkCuLock: async () => {
|
||||
const c = await checkComputerUseLock();
|
||||
const c = await checkComputerUseLock()
|
||||
switch (c.kind) {
|
||||
case 'free':
|
||||
return {
|
||||
holder: undefined,
|
||||
isSelf: false
|
||||
};
|
||||
return { holder: undefined, isSelf: false }
|
||||
case 'held_by_self':
|
||||
return {
|
||||
holder: getSessionId(),
|
||||
isSelf: true
|
||||
};
|
||||
return { holder: getSessionId(), isSelf: true }
|
||||
case 'blocked':
|
||||
return {
|
||||
holder: c.by,
|
||||
isSelf: false
|
||||
};
|
||||
return { holder: c.by, isSelf: false }
|
||||
}
|
||||
},
|
||||
|
||||
// Called only when checkCuLock returned `holder: undefined`. The O_EXCL
|
||||
// acquire is atomic — if another process grabbed it in the gap (rare),
|
||||
// throw so the tool fails instead of proceeding without the lock.
|
||||
@@ -205,9 +251,9 @@ export function buildSessionContext(): ComputerUseSessionContext {
|
||||
// but is possible under parallel tool-use interleaving — don't spam the
|
||||
// notification in that case.
|
||||
acquireCuLock: async () => {
|
||||
const r = await tryAcquireComputerUseLock();
|
||||
const r = await tryAcquireComputerUseLock()
|
||||
if (r.kind === 'blocked') {
|
||||
throw new Error(formatLockHeld(r.by));
|
||||
throw new Error(formatLockHeld(r.by))
|
||||
}
|
||||
if (r.fresh) {
|
||||
// Global Escape → abort. Consumes the event (PI defense — prompt
|
||||
@@ -215,26 +261,34 @@ export function buildSessionContext(): ComputerUseSessionContext {
|
||||
// CFRunLoopSource is processed by the drainRunLoop pump, so this
|
||||
// holds a pump retain until unregisterEscHotkey() in cleanup.ts.
|
||||
const escRegistered = registerEscHotkey(() => {
|
||||
logForDebugging('[cu-esc] user escape, aborting turn');
|
||||
tuc().abortController.abort();
|
||||
});
|
||||
logForDebugging('[cu-esc] user escape, aborting turn')
|
||||
tuc().abortController.abort()
|
||||
})
|
||||
tuc().sendOSNotification?.({
|
||||
message: escRegistered ? 'Claude is using your computer · press Esc to stop' : 'Claude is using your computer · press Ctrl+C to stop',
|
||||
notificationType: 'computer_use_enter'
|
||||
});
|
||||
message: escRegistered
|
||||
? 'Claude is using your computer · press Esc to stop'
|
||||
: 'Claude is using your computer · press Ctrl+C to stop',
|
||||
notificationType: 'computer_use_enter',
|
||||
})
|
||||
}
|
||||
},
|
||||
formatLockHeldMessage: formatLockHeld
|
||||
};
|
||||
|
||||
formatLockHeldMessage: formatLockHeld,
|
||||
}
|
||||
}
|
||||
|
||||
function getOrBind(): Binding {
|
||||
if (binding) return binding;
|
||||
const ctx = buildSessionContext();
|
||||
if (binding) return binding
|
||||
const ctx = buildSessionContext()
|
||||
binding = {
|
||||
ctx,
|
||||
dispatch: bindSessionContext(getComputerUseHostAdapter(), getChicagoCoordinateMode(), ctx)
|
||||
};
|
||||
return binding;
|
||||
dispatch: bindSessionContext(
|
||||
getComputerUseHostAdapter(),
|
||||
getChicagoCoordinateMode(),
|
||||
ctx,
|
||||
),
|
||||
}
|
||||
return binding
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,21 +296,25 @@ function getOrBind(): Binding {
|
||||
* tool: rendering overrides from `toolRendering.tsx` plus a `.call()` that
|
||||
* dispatches through the cached binder.
|
||||
*/
|
||||
type ComputerUseMCPToolOverrides = ReturnType<typeof getComputerUseMCPRenderingOverrides> & {
|
||||
call: CallOverride;
|
||||
};
|
||||
export function getComputerUseMCPToolOverrides(toolName: string): ComputerUseMCPToolOverrides {
|
||||
type ComputerUseMCPToolOverrides = ReturnType<
|
||||
typeof getComputerUseMCPRenderingOverrides
|
||||
> & {
|
||||
call: CallOverride
|
||||
}
|
||||
|
||||
export function getComputerUseMCPToolOverrides(
|
||||
toolName: string,
|
||||
): ComputerUseMCPToolOverrides {
|
||||
const call: CallOverride = async (args, context: ToolUseContext) => {
|
||||
currentToolUseContext = context;
|
||||
const {
|
||||
dispatch
|
||||
} = getOrBind();
|
||||
const {
|
||||
telemetry,
|
||||
...result
|
||||
} = await dispatch(toolName, args);
|
||||
currentToolUseContext = context
|
||||
const { dispatch } = getOrBind()
|
||||
|
||||
const { telemetry, ...result } = await dispatch(toolName, args)
|
||||
|
||||
if (telemetry?.error_kind) {
|
||||
logForDebugging(`[Computer Use MCP] ${toolName} error_kind=${telemetry.error_kind}`);
|
||||
logForDebugging(
|
||||
`[Computer Use MCP] ${toolName} error_kind=${telemetry.error_kind}`,
|
||||
)
|
||||
}
|
||||
|
||||
// MCP content blocks → Anthropic API blocks. CU only produces text and
|
||||
@@ -265,25 +323,30 @@ export function getComputerUseMCPToolOverrides(toolName: string): ComputerUseMCP
|
||||
// shape just maps to the API's base64-source shape. The package's result
|
||||
// type admits audio/resource too, but CU's handleToolCall never emits
|
||||
// those; the fallthrough coerces them to empty text.
|
||||
const data = Array.isArray(result.content) ? result.content.map(item => item.type === 'image' ? {
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64' as const,
|
||||
media_type: item.mimeType ?? 'image/jpeg',
|
||||
data: item.data
|
||||
}
|
||||
} : {
|
||||
type: 'text' as const,
|
||||
text: item.type === 'text' ? item.text : ''
|
||||
}) : result.content;
|
||||
return {
|
||||
data
|
||||
};
|
||||
};
|
||||
const data = Array.isArray(result.content)
|
||||
? result.content.map(item =>
|
||||
item.type === 'image'
|
||||
? {
|
||||
type: 'image' as const,
|
||||
source: {
|
||||
type: 'base64' as const,
|
||||
media_type: item.mimeType ?? 'image/jpeg',
|
||||
data: item.data,
|
||||
},
|
||||
}
|
||||
: {
|
||||
type: 'text' as const,
|
||||
text: item.type === 'text' ? item.text : '',
|
||||
},
|
||||
)
|
||||
: result.content
|
||||
return { data }
|
||||
}
|
||||
|
||||
return {
|
||||
...getComputerUseMCPRenderingOverrides(toolName),
|
||||
call
|
||||
};
|
||||
call,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -293,43 +356,43 @@ export function getComputerUseMCPToolOverrides(toolName: string): ComputerUseMCP
|
||||
* The merge-into-AppState that used to live here (dedupe + truthy-only flags)
|
||||
* is now in the package's `bindSessionContext` → `onAllowedAppsChanged`.
|
||||
*/
|
||||
async function runPermissionDialog(req: CuPermissionRequest): Promise<CuPermissionResponse> {
|
||||
const context = tuc();
|
||||
const setToolJSX = context.setToolJSX;
|
||||
async function runPermissionDialog(
|
||||
req: CuPermissionRequest,
|
||||
): Promise<CuPermissionResponse> {
|
||||
const context = tuc()
|
||||
const setToolJSX = context.setToolJSX
|
||||
if (!setToolJSX) {
|
||||
// Shouldn't happen — main.tsx gate excludes non-interactive. Fail safe.
|
||||
return {
|
||||
granted: [],
|
||||
denied: [],
|
||||
flags: DEFAULT_GRANT_FLAGS
|
||||
};
|
||||
return { granted: [], denied: [], flags: DEFAULT_GRANT_FLAGS }
|
||||
}
|
||||
|
||||
try {
|
||||
return await new Promise<CuPermissionResponse>((resolve, reject) => {
|
||||
const signal = context.abortController.signal;
|
||||
const signal = context.abortController.signal
|
||||
// If already aborted, addEventListener won't fire — reject now so the
|
||||
// promise doesn't hang waiting for a user who Ctrl+C'd.
|
||||
if (signal.aborted) {
|
||||
reject(new Error('Computer Use permission dialog aborted'));
|
||||
return;
|
||||
reject(new Error('Computer Use permission dialog aborted'))
|
||||
return
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
reject(new Error('Computer Use permission dialog aborted'));
|
||||
};
|
||||
signal.addEventListener('abort', onAbort);
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
reject(new Error('Computer Use permission dialog aborted'))
|
||||
}
|
||||
signal.addEventListener('abort', onAbort)
|
||||
|
||||
setToolJSX({
|
||||
jsx: React.createElement(ComputerUseApproval, {
|
||||
request: req,
|
||||
onDone: (resp: CuPermissionResponse) => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
resolve(resp);
|
||||
}
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve(resp)
|
||||
},
|
||||
}),
|
||||
shouldHidePromptInput: true
|
||||
});
|
||||
});
|
||||
shouldHidePromptInput: true,
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
setToolJSX(null);
|
||||
setToolJSX(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { useRef } from 'react';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import { Messages } from '../components/Messages.js';
|
||||
import { KeybindingProvider } from '../keybindings/KeybindingContext.js';
|
||||
import { loadKeybindingsSyncWithWarnings } from '../keybindings/loadUserBindings.js';
|
||||
import type { KeybindingContextName } from '../keybindings/types.js';
|
||||
import { AppStateProvider } from '../state/AppState.js';
|
||||
import type { Tools } from '../Tool.js';
|
||||
import type { Message } from '../types/message.js';
|
||||
import { renderToAnsiString } from './staticRender.js';
|
||||
import React, { useRef } from 'react'
|
||||
import stripAnsi from 'strip-ansi'
|
||||
import { Messages } from '../components/Messages.js'
|
||||
import { KeybindingProvider } from '../keybindings/KeybindingContext.js'
|
||||
import { loadKeybindingsSyncWithWarnings } from '../keybindings/loadUserBindings.js'
|
||||
import type { KeybindingContextName } from '../keybindings/types.js'
|
||||
import { AppStateProvider } from '../state/AppState.js'
|
||||
import type { Tools } from '../Tool.js'
|
||||
import type { Message } from '../types/message.js'
|
||||
import { renderToAnsiString } from './staticRender.js'
|
||||
|
||||
/**
|
||||
* Minimal keybinding provider for static/headless renders.
|
||||
@@ -15,19 +15,29 @@ import { renderToAnsiString } from './staticRender.js';
|
||||
* and would hang in headless renders with no stdin).
|
||||
*/
|
||||
function StaticKeybindingProvider({
|
||||
children
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode
|
||||
}): React.ReactNode {
|
||||
const {
|
||||
bindings
|
||||
} = loadKeybindingsSyncWithWarnings();
|
||||
const pendingChordRef = useRef(null);
|
||||
const handlerRegistryRef = useRef(new Map());
|
||||
const activeContexts = useRef(new Set<KeybindingContextName>()).current;
|
||||
return <KeybindingProvider bindings={bindings} pendingChordRef={pendingChordRef} pendingChord={null} setPendingChord={() => {}} activeContexts={activeContexts} registerActiveContext={() => {}} unregisterActiveContext={() => {}} handlerRegistryRef={handlerRegistryRef}>
|
||||
const { bindings } = loadKeybindingsSyncWithWarnings()
|
||||
const pendingChordRef = useRef(null)
|
||||
const handlerRegistryRef = useRef(new Map())
|
||||
const activeContexts = useRef(new Set<KeybindingContextName>()).current
|
||||
|
||||
return (
|
||||
<KeybindingProvider
|
||||
bindings={bindings}
|
||||
pendingChordRef={pendingChordRef}
|
||||
pendingChord={null}
|
||||
setPendingChord={() => {}}
|
||||
activeContexts={activeContexts}
|
||||
registerActiveContext={() => {}}
|
||||
unregisterActiveContext={() => {}}
|
||||
handlerRegistryRef={handlerRegistryRef}
|
||||
>
|
||||
{children}
|
||||
</KeybindingProvider>;
|
||||
</KeybindingProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Upper-bound how many NormalizedMessages a Message can produce.
|
||||
@@ -35,9 +45,9 @@ function StaticKeybindingProvider({
|
||||
// NormalizedMessages — 1:1 with block count. String content = 1 block.
|
||||
// AttachmentMessage etc. have no .message and normalize to ≤1.
|
||||
function normalizedUpperBound(m: Message): number {
|
||||
if (!('message' in m)) return 1;
|
||||
const c = m.message.content;
|
||||
return Array.isArray(c) ? c.length : 1;
|
||||
if (!('message' in m)) return 1
|
||||
const c = m.message.content
|
||||
return Array.isArray(c) ? c.length : 1
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,35 +62,59 @@ function normalizedUpperBound(m: Message): number {
|
||||
* the full normalized array so tool_use↔tool_result resolves regardless of
|
||||
* which chunk each landed in.
|
||||
*/
|
||||
export async function streamRenderedMessages(messages: Message[], tools: Tools, sink: (ansiChunk: string) => void | Promise<void>, {
|
||||
columns,
|
||||
verbose = false,
|
||||
chunkSize = 40,
|
||||
onProgress
|
||||
}: {
|
||||
columns?: number;
|
||||
verbose?: boolean;
|
||||
chunkSize?: number;
|
||||
onProgress?: (rendered: number) => void;
|
||||
} = {}): Promise<void> {
|
||||
const renderChunk = (range: readonly [number, number]) => renderToAnsiString(<AppStateProvider>
|
||||
export async function streamRenderedMessages(
|
||||
messages: Message[],
|
||||
tools: Tools,
|
||||
sink: (ansiChunk: string) => void | Promise<void>,
|
||||
{
|
||||
columns,
|
||||
verbose = false,
|
||||
chunkSize = 40,
|
||||
onProgress,
|
||||
}: {
|
||||
columns?: number
|
||||
verbose?: boolean
|
||||
chunkSize?: number
|
||||
onProgress?: (rendered: number) => void
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const renderChunk = (range: readonly [number, number]) =>
|
||||
renderToAnsiString(
|
||||
<AppStateProvider>
|
||||
<StaticKeybindingProvider>
|
||||
<Messages messages={messages} tools={tools} commands={[]} verbose={verbose} toolJSX={null} toolUseConfirmQueue={[]} inProgressToolUseIDs={new Set()} isMessageSelectorVisible={false} conversationId="export" screen="prompt" streamingToolUses={[]} showAllInTranscript={true} isLoading={false} renderRange={range} />
|
||||
<Messages
|
||||
messages={messages}
|
||||
tools={tools}
|
||||
commands={[]}
|
||||
verbose={verbose}
|
||||
toolJSX={null}
|
||||
toolUseConfirmQueue={[]}
|
||||
inProgressToolUseIDs={new Set()}
|
||||
isMessageSelectorVisible={false}
|
||||
conversationId="export"
|
||||
screen="prompt"
|
||||
streamingToolUses={[]}
|
||||
showAllInTranscript={true}
|
||||
isLoading={false}
|
||||
renderRange={range}
|
||||
/>
|
||||
</StaticKeybindingProvider>
|
||||
</AppStateProvider>, columns);
|
||||
</AppStateProvider>,
|
||||
columns,
|
||||
)
|
||||
|
||||
// renderRange indexes into the post-collapse array whose length we can't
|
||||
// see from here — normalize splits each Message into one NormalizedMessage
|
||||
// per content block (unbounded per message), collapse merges some back.
|
||||
// Ceiling is the exact normalize output count + chunkSize so the loop
|
||||
// always reaches the empty slice where break fires (collapse only shrinks).
|
||||
let ceiling = chunkSize;
|
||||
for (const m of messages) ceiling += normalizedUpperBound(m);
|
||||
let ceiling = chunkSize
|
||||
for (const m of messages) ceiling += normalizedUpperBound(m)
|
||||
for (let offset = 0; offset < ceiling; offset += chunkSize) {
|
||||
const ansi = await renderChunk([offset, offset + chunkSize]);
|
||||
if (stripAnsi(ansi).trim() === '') break;
|
||||
await sink(ansi);
|
||||
onProgress?.(offset + chunkSize);
|
||||
const ansi = await renderChunk([offset, offset + chunkSize])
|
||||
if (stripAnsi(ansi).trim() === '') break
|
||||
await sink(ansi)
|
||||
onProgress?.(offset + chunkSize)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,10 +122,17 @@ export async function streamRenderedMessages(messages: Message[], tools: Tools,
|
||||
* Renders messages to a plain text string suitable for export.
|
||||
* Uses the same React rendering logic as the interactive UI.
|
||||
*/
|
||||
export async function renderMessagesToPlainText(messages: Message[], tools: Tools = [], columns?: number): Promise<string> {
|
||||
const parts: string[] = [];
|
||||
await streamRenderedMessages(messages, tools, chunk => void parts.push(stripAnsi(chunk)), {
|
||||
columns
|
||||
});
|
||||
return parts.join('');
|
||||
export async function renderMessagesToPlainText(
|
||||
messages: Message[],
|
||||
tools: Tools = [],
|
||||
columns?: number,
|
||||
): Promise<string> {
|
||||
const parts: string[] = []
|
||||
await streamRenderedMessages(
|
||||
messages,
|
||||
tools,
|
||||
chunk => void parts.push(stripAnsi(chunk)),
|
||||
{ columns },
|
||||
)
|
||||
return parts.join('')
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { Text } from '../ink.js';
|
||||
import * as React from 'react'
|
||||
import { Text } from '../ink.js'
|
||||
|
||||
/**
|
||||
* Inverse-highlight every occurrence of `query` in `text` (case-insensitive).
|
||||
@@ -7,21 +7,23 @@ import { Text } from '../ink.js';
|
||||
* and preview panes.
|
||||
*/
|
||||
export function highlightMatch(text: string, query: string): React.ReactNode {
|
||||
if (!query) return text;
|
||||
const queryLower = query.toLowerCase();
|
||||
const textLower = text.toLowerCase();
|
||||
const parts: React.ReactNode[] = [];
|
||||
let offset = 0;
|
||||
let idx = textLower.indexOf(queryLower, offset);
|
||||
if (idx === -1) return text;
|
||||
if (!query) return text
|
||||
const queryLower = query.toLowerCase()
|
||||
const textLower = text.toLowerCase()
|
||||
const parts: React.ReactNode[] = []
|
||||
let offset = 0
|
||||
let idx = textLower.indexOf(queryLower, offset)
|
||||
if (idx === -1) return text
|
||||
while (idx !== -1) {
|
||||
if (idx > offset) parts.push(text.slice(offset, idx));
|
||||
parts.push(<Text key={idx} inverse>
|
||||
if (idx > offset) parts.push(text.slice(offset, idx))
|
||||
parts.push(
|
||||
<Text key={idx} inverse>
|
||||
{text.slice(idx, idx + query.length)}
|
||||
</Text>);
|
||||
offset = idx + query.length;
|
||||
idx = textLower.indexOf(queryLower, offset);
|
||||
</Text>,
|
||||
)
|
||||
offset = idx + query.length
|
||||
idx = textLower.indexOf(queryLower, offset)
|
||||
}
|
||||
if (offset < text.length) parts.push(text.slice(offset));
|
||||
return <>{parts}</>;
|
||||
if (offset < text.length) parts.push(text.slice(offset))
|
||||
return <>{parts}</>
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { performBackgroundPluginInstallations } from '../../services/plugins/PluginInstallationManager.js';
|
||||
import type { AppState } from '../../state/AppState.js';
|
||||
import { checkHasTrustDialogAccepted } from '../config.js';
|
||||
import { logForDebugging } from '../debug.js';
|
||||
import { clearMarketplacesCache, registerSeedMarketplaces } from './marketplaceManager.js';
|
||||
import { clearPluginCache } from './pluginLoader.js';
|
||||
type SetAppState = (f: (prevState: AppState) => AppState) => void;
|
||||
import { performBackgroundPluginInstallations } from '../../services/plugins/PluginInstallationManager.js'
|
||||
import type { AppState } from '../../state/AppState.js'
|
||||
import { checkHasTrustDialogAccepted } from '../config.js'
|
||||
import { logForDebugging } from '../debug.js'
|
||||
import {
|
||||
clearMarketplacesCache,
|
||||
registerSeedMarketplaces,
|
||||
} from './marketplaceManager.js'
|
||||
import { clearPluginCache } from './pluginLoader.js'
|
||||
|
||||
type SetAppState = (f: (prevState: AppState) => AppState) => void
|
||||
|
||||
/**
|
||||
* Perform plugin startup checks and initiate background installations
|
||||
@@ -21,16 +25,21 @@ type SetAppState = (f: (prevState: AppState) => AppState) => void;
|
||||
*
|
||||
* @param setAppState Function to update app state with installation progress
|
||||
*/
|
||||
export async function performStartupChecks(setAppState: SetAppState): Promise<void> {
|
||||
logForDebugging('performStartupChecks called');
|
||||
export async function performStartupChecks(
|
||||
setAppState: SetAppState,
|
||||
): Promise<void> {
|
||||
logForDebugging('performStartupChecks called')
|
||||
|
||||
// Check if the current directory has been trusted
|
||||
if (!checkHasTrustDialogAccepted()) {
|
||||
logForDebugging('Trust not accepted for current directory - skipping plugin installations');
|
||||
return;
|
||||
logForDebugging(
|
||||
'Trust not accepted for current directory - skipping plugin installations',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
logForDebugging('Starting background plugin installations');
|
||||
logForDebugging('Starting background plugin installations')
|
||||
|
||||
// Register seed marketplaces (CLAUDE_CODE_PLUGIN_SEED_DIR) before diffing.
|
||||
// Idempotent; no-op if seed not configured. Without this, background install
|
||||
@@ -39,31 +48,30 @@ export async function performStartupChecks(setAppState: SetAppState): Promise<vo
|
||||
// If registration changed state, clear caches so earlier plugin-load passes
|
||||
// (e.g. getAllMcpConfigs during REPL init) don't keep stale "marketplace
|
||||
// not found" results.
|
||||
const seedChanged = await registerSeedMarketplaces();
|
||||
const seedChanged = await registerSeedMarketplaces()
|
||||
if (seedChanged) {
|
||||
clearMarketplacesCache();
|
||||
clearPluginCache('performStartupChecks: seed marketplaces changed');
|
||||
clearMarketplacesCache()
|
||||
clearPluginCache('performStartupChecks: seed marketplaces changed')
|
||||
// Set needsRefresh so useManagePlugins notifies the user to run
|
||||
// /reload-plugins. Without this signal, the initial plugin-load
|
||||
// (which raced and cached "marketplace not found") would persist
|
||||
// until the user manually reloads.
|
||||
setAppState(prev => {
|
||||
if (prev.plugins.needsRefresh) return prev;
|
||||
if (prev.plugins.needsRefresh) return prev
|
||||
return {
|
||||
...prev,
|
||||
plugins: {
|
||||
...prev.plugins,
|
||||
needsRefresh: true
|
||||
}
|
||||
};
|
||||
});
|
||||
plugins: { ...prev.plugins, needsRefresh: true },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Start background installations without waiting
|
||||
// This will update AppState as installations progress
|
||||
await performBackgroundPluginInstallations(setAppState);
|
||||
await performBackgroundPluginInstallations(setAppState)
|
||||
} catch (error) {
|
||||
// Even if something fails here, don't block startup
|
||||
logForDebugging(`Error initiating background plugin installations: ${error}`);
|
||||
logForDebugging(
|
||||
`Error initiating background plugin installations: ${error}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,134 +1,152 @@
|
||||
import axios from 'axios';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { logEvent } from 'src/services/analytics/index.js';
|
||||
import { Spinner } from '../components/Spinner.js';
|
||||
import { getOauthConfig } from '../constants/oauth.js';
|
||||
import { useTimeout } from '../hooks/useTimeout.js';
|
||||
import { Box, Text } from '../ink.js';
|
||||
import { getSSLErrorHint } from '../services/api/errorUtils.js';
|
||||
import { getUserAgent } from './http.js';
|
||||
import { logError } from './log.js';
|
||||
import axios from 'axios'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import { Spinner } from '../components/Spinner.js'
|
||||
import { getOauthConfig } from '../constants/oauth.js'
|
||||
import { useTimeout } from '../hooks/useTimeout.js'
|
||||
import { Box, Text } from '../ink.js'
|
||||
import { getSSLErrorHint } from '../services/api/errorUtils.js'
|
||||
import { getUserAgent } from './http.js'
|
||||
import { logError } from './log.js'
|
||||
|
||||
export interface PreflightCheckResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
sslHint?: string;
|
||||
success: boolean
|
||||
error?: string
|
||||
sslHint?: string
|
||||
}
|
||||
|
||||
async function checkEndpoints(): Promise<PreflightCheckResult> {
|
||||
try {
|
||||
const oauthConfig = getOauthConfig();
|
||||
const tokenUrl = new URL(oauthConfig.TOKEN_URL);
|
||||
const endpoints = [`${oauthConfig.BASE_API_URL}/api/hello`, `${tokenUrl.origin}/v1/oauth/hello`];
|
||||
const checkEndpoint = async (url: string): Promise<PreflightCheckResult> => {
|
||||
const oauthConfig = getOauthConfig()
|
||||
const tokenUrl = new URL(oauthConfig.TOKEN_URL)
|
||||
const endpoints = [
|
||||
`${oauthConfig.BASE_API_URL}/api/hello`,
|
||||
`${tokenUrl.origin}/v1/oauth/hello`,
|
||||
]
|
||||
|
||||
const checkEndpoint = async (
|
||||
url: string,
|
||||
): Promise<PreflightCheckResult> => {
|
||||
try {
|
||||
const response = await axios.get(url, {
|
||||
headers: {
|
||||
'User-Agent': getUserAgent()
|
||||
}
|
||||
});
|
||||
headers: { 'User-Agent': getUserAgent() },
|
||||
})
|
||||
if (response.status !== 200) {
|
||||
const hostname = new URL(url).hostname;
|
||||
const hostname = new URL(url).hostname
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to connect to ${hostname}: Status ${response.status}`
|
||||
};
|
||||
error: `Failed to connect to ${hostname}: Status ${response.status}`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true
|
||||
};
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const hostname = new URL(url).hostname;
|
||||
const sslHint = getSSLErrorHint(error);
|
||||
const hostname = new URL(url).hostname
|
||||
const sslHint = getSSLErrorHint(error)
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to connect to ${hostname}: ${error instanceof Error ? (error as ErrnoException).code || error.message : String(error)}`,
|
||||
sslHint: sslHint ?? undefined
|
||||
};
|
||||
sslHint: sslHint ?? undefined,
|
||||
}
|
||||
}
|
||||
};
|
||||
const results = await Promise.all(endpoints.map(checkEndpoint));
|
||||
const failedResult = results.find(result => !result.success);
|
||||
}
|
||||
|
||||
const results = await Promise.all(endpoints.map(checkEndpoint))
|
||||
const failedResult = results.find(result => !result.success)
|
||||
|
||||
if (failedResult) {
|
||||
// Log failure to Statsig
|
||||
logEvent('tengu_preflight_check_failed', {
|
||||
isConnectivityError: false,
|
||||
hasErrorMessage: !!failedResult.error,
|
||||
isSSLError: !!failedResult.sslHint
|
||||
});
|
||||
isSSLError: !!failedResult.sslHint,
|
||||
})
|
||||
}
|
||||
return failedResult || {
|
||||
success: true
|
||||
};
|
||||
|
||||
return failedResult || { success: true }
|
||||
} catch (error) {
|
||||
logError(error as Error);
|
||||
logError(error as Error)
|
||||
|
||||
// Log to Statsig
|
||||
logEvent('tengu_preflight_check_failed', {
|
||||
isConnectivityError: true
|
||||
});
|
||||
isConnectivityError: true,
|
||||
})
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Connectivity check error: ${error instanceof Error ? (error as ErrnoException).code || error.message : String(error)}`
|
||||
};
|
||||
error: `Connectivity check error: ${error instanceof Error ? (error as ErrnoException).code || error.message : String(error)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface PreflightStepProps {
|
||||
onSuccess: () => void;
|
||||
onSuccess: () => void
|
||||
}
|
||||
export function PreflightStep({ onSuccess }: PreflightStepProps) {
|
||||
const [result, setResult] = useState<PreflightCheckResult | null>(null);
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const showSpinner = useTimeout(1000) && isChecking;
|
||||
|
||||
export function PreflightStep({
|
||||
onSuccess,
|
||||
}: PreflightStepProps): React.ReactNode {
|
||||
const [result, setResult] = useState<PreflightCheckResult | null>(null)
|
||||
const [isChecking, setIsChecking] = useState(true)
|
||||
|
||||
// delay showing the check since it's so fast that we normally
|
||||
// want to just immediately show the next step without a flash
|
||||
const showSpinner = useTimeout(1000) && isChecking
|
||||
|
||||
useEffect(() => {
|
||||
checkEndpoints().then((checkResult) => {
|
||||
setResult(checkResult);
|
||||
setIsChecking(false);
|
||||
});
|
||||
}, []);
|
||||
async function run() {
|
||||
const checkResult = await checkEndpoints()
|
||||
setResult(checkResult)
|
||||
setIsChecking(false)
|
||||
}
|
||||
void run()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (result?.success) {
|
||||
onSuccess();
|
||||
onSuccess()
|
||||
} else if (result && !result.success) {
|
||||
logError(result.error ?? 'Preflight connectivity check failed');
|
||||
onSuccess();
|
||||
const timer = setTimeout(() => process.exit(1), 100)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [result, onSuccess]);
|
||||
|
||||
let content = null;
|
||||
if (isChecking && showSpinner) {
|
||||
content = (
|
||||
<Box paddingLeft={1}>
|
||||
<Spinner />
|
||||
<Text>Checking connectivity...</Text>
|
||||
</Box>
|
||||
);
|
||||
} else if (!result?.success && !isChecking) {
|
||||
content = (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="error">Unable to connect to Anthropic services</Text>
|
||||
<Text color="error">{result?.error}</Text>
|
||||
{result?.sslHint ? (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>{result.sslHint}</Text>
|
||||
<Text color="suggestion">See https://code.claude.com/docs/en/network-config</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>Please check your internet connection and network settings.</Text>
|
||||
<Text>
|
||||
Note: Claude Code might not be available in your country. Check supported countries at{" "}
|
||||
<Text color="suggestion">https://anthropic.com/supported-countries</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}, [result, onSuccess])
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1} paddingLeft={1}>
|
||||
{content}
|
||||
{isChecking && showSpinner ? (
|
||||
<Box paddingLeft={1}>
|
||||
<Spinner />
|
||||
<Text>Checking connectivity...</Text>
|
||||
</Box>
|
||||
) : (
|
||||
!result?.success &&
|
||||
!isChecking && (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="error">Unable to connect to Anthropic services</Text>
|
||||
<Text color="error">{result?.error}</Text>
|
||||
{result?.sslHint ? (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>{result.sslHint}</Text>
|
||||
<Text color="suggestion">
|
||||
See https://code.claude.com/docs/en/network-config
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>
|
||||
Please check your internet connection and network settings.
|
||||
</Text>
|
||||
<Text>
|
||||
Note: Claude Code might not be available in your country.
|
||||
Check supported countries at{' '}
|
||||
<Text color="suggestion">
|
||||
https://anthropic.com/supported-countries
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,69 +1,97 @@
|
||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources';
|
||||
import { randomUUID } from 'crypto';
|
||||
import * as React from 'react';
|
||||
import { BashModeProgress } from 'src/components/BashModeProgress.js';
|
||||
import type { SetToolJSXFn } from 'src/Tool.js';
|
||||
import { BashTool } from 'src/tools/BashTool/BashTool.js';
|
||||
import type { AttachmentMessage, SystemMessage, UserMessage } from 'src/types/message.js';
|
||||
import type { ShellProgress } from 'src/types/tools.js';
|
||||
import { logEvent } from '../../services/analytics/index.js';
|
||||
import { errorMessage, ShellError } from '../errors.js';
|
||||
import { createSyntheticUserCaveatMessage, createUserInterruptionMessage, createUserMessage, prepareUserContent } from '../messages.js';
|
||||
import { resolveDefaultShell } from '../shell/resolveDefaultShell.js';
|
||||
import { isPowerShellToolEnabled } from '../shell/shellToolUtils.js';
|
||||
import { processToolResultBlock } from '../toolResultStorage.js';
|
||||
import { escapeXml } from '../xml.js';
|
||||
import type { ProcessUserInputContext } from './processUserInput.js';
|
||||
export async function processBashCommand(inputString: string, precedingInputBlocks: ContentBlockParam[], attachmentMessages: AttachmentMessage[], context: ProcessUserInputContext, setToolJSX: SetToolJSXFn): Promise<{
|
||||
messages: (UserMessage | AttachmentMessage | SystemMessage)[];
|
||||
shouldQuery: boolean;
|
||||
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources'
|
||||
import { randomUUID } from 'crypto'
|
||||
import * as React from 'react'
|
||||
import { BashModeProgress } from 'src/components/BashModeProgress.js'
|
||||
import type { SetToolJSXFn } from 'src/Tool.js'
|
||||
import { BashTool } from 'src/tools/BashTool/BashTool.js'
|
||||
import type {
|
||||
AttachmentMessage,
|
||||
SystemMessage,
|
||||
UserMessage,
|
||||
} from 'src/types/message.js'
|
||||
import type { ShellProgress } from 'src/types/tools.js'
|
||||
import { logEvent } from '../../services/analytics/index.js'
|
||||
import { errorMessage, ShellError } from '../errors.js'
|
||||
import {
|
||||
createSyntheticUserCaveatMessage,
|
||||
createUserInterruptionMessage,
|
||||
createUserMessage,
|
||||
prepareUserContent,
|
||||
} from '../messages.js'
|
||||
import { resolveDefaultShell } from '../shell/resolveDefaultShell.js'
|
||||
import { isPowerShellToolEnabled } from '../shell/shellToolUtils.js'
|
||||
import { processToolResultBlock } from '../toolResultStorage.js'
|
||||
import { escapeXml } from '../xml.js'
|
||||
import type { ProcessUserInputContext } from './processUserInput.js'
|
||||
|
||||
export async function processBashCommand(
|
||||
inputString: string,
|
||||
precedingInputBlocks: ContentBlockParam[],
|
||||
attachmentMessages: AttachmentMessage[],
|
||||
context: ProcessUserInputContext,
|
||||
setToolJSX: SetToolJSXFn,
|
||||
): Promise<{
|
||||
messages: (UserMessage | AttachmentMessage | SystemMessage)[]
|
||||
shouldQuery: boolean
|
||||
}> {
|
||||
// Shell routing (docs/design/ps-shell-selection.md §5.2): consult
|
||||
// defaultShell, fall back to bash. isPowerShellToolEnabled() applies the
|
||||
// same platform + env-var gate as tools.ts so input-box routing matches
|
||||
// tool-list visibility. Computed up front so telemetry records the
|
||||
// actual shell, not the raw setting.
|
||||
const usePowerShell = isPowerShellToolEnabled() && resolveDefaultShell() === 'powershell';
|
||||
logEvent('tengu_input_bash', {
|
||||
powershell: usePowerShell
|
||||
});
|
||||
const usePowerShell =
|
||||
isPowerShellToolEnabled() && resolveDefaultShell() === 'powershell'
|
||||
|
||||
logEvent('tengu_input_bash', { powershell: usePowerShell })
|
||||
|
||||
const userMessage = createUserMessage({
|
||||
content: prepareUserContent({
|
||||
inputString: `<bash-input>${inputString}</bash-input>`,
|
||||
precedingInputBlocks
|
||||
})
|
||||
});
|
||||
precedingInputBlocks,
|
||||
}),
|
||||
})
|
||||
|
||||
// ctrl+b to background indicator
|
||||
let jsx: React.ReactNode;
|
||||
let jsx: React.ReactNode
|
||||
|
||||
// Just show initial UI
|
||||
setToolJSX({
|
||||
jsx: <BashModeProgress input={inputString} progress={null} verbose={context.options.verbose} />,
|
||||
shouldHidePromptInput: false
|
||||
});
|
||||
jsx: (
|
||||
<BashModeProgress
|
||||
input={inputString}
|
||||
progress={null}
|
||||
verbose={context.options.verbose}
|
||||
/>
|
||||
),
|
||||
shouldHidePromptInput: false,
|
||||
})
|
||||
|
||||
try {
|
||||
const bashModeContext: ProcessUserInputContext = {
|
||||
...context,
|
||||
// TODO: Clean up this hack
|
||||
setToolJSX: _ => {
|
||||
jsx = _?.jsx;
|
||||
}
|
||||
};
|
||||
jsx = _?.jsx
|
||||
},
|
||||
}
|
||||
|
||||
// Progress UI — shared across both shell backends (both emit ShellProgress)
|
||||
const onProgress = (progress: {
|
||||
data: ShellProgress;
|
||||
}) => {
|
||||
const onProgress = (progress: { data: ShellProgress }) => {
|
||||
setToolJSX({
|
||||
jsx: <>
|
||||
<BashModeProgress input={inputString!} progress={progress.data} verbose={context.options.verbose} />
|
||||
jsx: (
|
||||
<>
|
||||
<BashModeProgress
|
||||
input={inputString!}
|
||||
progress={progress.data}
|
||||
verbose={context.options.verbose}
|
||||
/>
|
||||
{jsx}
|
||||
</>,
|
||||
</>
|
||||
),
|
||||
shouldHidePromptInput: false,
|
||||
showSpinner: false
|
||||
});
|
||||
};
|
||||
showSpinner: false,
|
||||
})
|
||||
}
|
||||
|
||||
// User-initiated `!` commands run outside sandbox. Both shell tools honor
|
||||
// dangerouslyDisableSandbox (checked against areUnsandboxedCommandsAllowed()
|
||||
@@ -71,69 +99,107 @@ export async function processBashCommand(inputString: string, precedingInputBloc
|
||||
// native, shouldUseSandbox() returns false regardless (unsupported platform).
|
||||
// Lazy-require PowerShellTool so its ~300KB chunk only loads when the
|
||||
// user has actually selected the powershell default shell.
|
||||
type PSMod = typeof import('src/tools/PowerShellTool/PowerShellTool.js');
|
||||
let PowerShellTool: PSMod['PowerShellTool'] | null = null;
|
||||
type PSMod = typeof import('src/tools/PowerShellTool/PowerShellTool.js')
|
||||
let PowerShellTool: PSMod['PowerShellTool'] | null = null
|
||||
if (usePowerShell) {
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
PowerShellTool = (require('src/tools/PowerShellTool/PowerShellTool.js') as PSMod).PowerShellTool;
|
||||
PowerShellTool = (
|
||||
require('src/tools/PowerShellTool/PowerShellTool.js') as PSMod
|
||||
).PowerShellTool
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
}
|
||||
const shellTool = PowerShellTool ?? BashTool;
|
||||
const response = PowerShellTool ? await PowerShellTool.call({
|
||||
command: inputString,
|
||||
dangerouslyDisableSandbox: true
|
||||
}, bashModeContext, undefined, undefined, onProgress) : await BashTool.call({
|
||||
command: inputString,
|
||||
dangerouslyDisableSandbox: true
|
||||
}, bashModeContext, undefined, undefined, onProgress);
|
||||
const data = response.data;
|
||||
const shellTool = PowerShellTool ?? BashTool
|
||||
|
||||
const response = PowerShellTool
|
||||
? await PowerShellTool.call(
|
||||
{ command: inputString, dangerouslyDisableSandbox: true },
|
||||
bashModeContext,
|
||||
undefined,
|
||||
undefined,
|
||||
onProgress,
|
||||
)
|
||||
: await BashTool.call(
|
||||
{
|
||||
command: inputString,
|
||||
dangerouslyDisableSandbox: true,
|
||||
},
|
||||
bashModeContext,
|
||||
undefined,
|
||||
undefined,
|
||||
onProgress,
|
||||
)
|
||||
const data = response.data
|
||||
|
||||
if (!data) {
|
||||
throw new Error('No result received from shell command');
|
||||
throw new Error('No result received from shell command')
|
||||
}
|
||||
const stderr = data.stderr;
|
||||
|
||||
const stderr = data.stderr
|
||||
// Reuse the same formatting pipeline as inline !`cmd` bash (promptShellExecution)
|
||||
// and model-initiated Bash. When BashTool.call() persists large output to disk,
|
||||
// data.persistedOutputPath is set and the formatter wraps in <persisted-output>.
|
||||
// Pass stderr:'' to keep it separate for the <bash-stderr> UI tag.
|
||||
const mapped = await processToolResultBlock(shellTool, {
|
||||
...data,
|
||||
stderr: ''
|
||||
}, randomUUID());
|
||||
const mapped = await processToolResultBlock(
|
||||
shellTool,
|
||||
{ ...data, stderr: '' },
|
||||
randomUUID(),
|
||||
)
|
||||
// mapped.content may contain our own <persisted-output> wrapper (trusted
|
||||
// XML from buildLargeToolResultMessage). Escaping it would turn structural
|
||||
// tags into <persisted-output>, breaking the model's parse and
|
||||
// UserBashOutputMessage's extractTag. Escape the raw fallback only.
|
||||
const stdout = typeof mapped.content === 'string' ? mapped.content : escapeXml(data.stdout);
|
||||
const stdout =
|
||||
typeof mapped.content === 'string'
|
||||
? mapped.content
|
||||
: escapeXml(data.stdout)
|
||||
return {
|
||||
messages: [createSyntheticUserCaveatMessage(), userMessage, ...attachmentMessages, createUserMessage({
|
||||
content: `<bash-stdout>${stdout}</bash-stdout><bash-stderr>${escapeXml(stderr)}</bash-stderr>`
|
||||
})],
|
||||
shouldQuery: false
|
||||
};
|
||||
messages: [
|
||||
createSyntheticUserCaveatMessage(),
|
||||
userMessage,
|
||||
...attachmentMessages,
|
||||
createUserMessage({
|
||||
content: `<bash-stdout>${stdout}</bash-stdout><bash-stderr>${escapeXml(stderr)}</bash-stderr>`,
|
||||
}),
|
||||
],
|
||||
shouldQuery: false,
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof ShellError) {
|
||||
if (e.interrupted) {
|
||||
return {
|
||||
messages: [createSyntheticUserCaveatMessage(), userMessage, createUserInterruptionMessage({
|
||||
toolUse: false
|
||||
}), ...attachmentMessages],
|
||||
shouldQuery: false
|
||||
};
|
||||
messages: [
|
||||
createSyntheticUserCaveatMessage(),
|
||||
userMessage,
|
||||
createUserInterruptionMessage({ toolUse: false }),
|
||||
...attachmentMessages,
|
||||
],
|
||||
shouldQuery: false,
|
||||
}
|
||||
}
|
||||
return {
|
||||
messages: [createSyntheticUserCaveatMessage(), userMessage, ...attachmentMessages, createUserMessage({
|
||||
content: `<bash-stdout>${escapeXml(e.stdout)}</bash-stdout><bash-stderr>${escapeXml(e.stderr)}</bash-stderr>`
|
||||
})],
|
||||
shouldQuery: false
|
||||
};
|
||||
messages: [
|
||||
createSyntheticUserCaveatMessage(),
|
||||
userMessage,
|
||||
...attachmentMessages,
|
||||
createUserMessage({
|
||||
content: `<bash-stdout>${escapeXml(e.stdout)}</bash-stdout><bash-stderr>${escapeXml(e.stderr)}</bash-stderr>`,
|
||||
}),
|
||||
],
|
||||
shouldQuery: false,
|
||||
}
|
||||
}
|
||||
return {
|
||||
messages: [createSyntheticUserCaveatMessage(), userMessage, ...attachmentMessages, createUserMessage({
|
||||
content: `<bash-stderr>Command failed: ${escapeXml(errorMessage(e))}</bash-stderr>`
|
||||
})],
|
||||
shouldQuery: false
|
||||
};
|
||||
messages: [
|
||||
createSyntheticUserCaveatMessage(),
|
||||
userMessage,
|
||||
...attachmentMessages,
|
||||
createUserMessage({
|
||||
content: `<bash-stderr>Command failed: ${escapeXml(errorMessage(e))}</bash-stderr>`,
|
||||
}),
|
||||
],
|
||||
shouldQuery: false,
|
||||
}
|
||||
} finally {
|
||||
setToolJSX(null);
|
||||
setToolJSX(null)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,8 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import * as React from 'react';
|
||||
import { useLayoutEffect } from 'react';
|
||||
import { PassThrough } from 'stream';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
import { render, useApp } from '../ink.js';
|
||||
import * as React from 'react'
|
||||
import { useLayoutEffect } from 'react'
|
||||
import { PassThrough } from 'stream'
|
||||
import stripAnsi from 'strip-ansi'
|
||||
import { render, useApp } from '../ink.js'
|
||||
|
||||
// This is a workaround for the fact that Ink doesn't support multiple <Static>
|
||||
// components in the same render tree. Instead of using a <Static> we just render
|
||||
@@ -15,44 +14,26 @@ import { render, useApp } from '../ink.js';
|
||||
* before exiting. This is more robust than process.nextTick() for React 19's
|
||||
* async render cycle.
|
||||
*/
|
||||
function RenderOnceAndExit(t0) {
|
||||
const $ = _c(5);
|
||||
const {
|
||||
children
|
||||
} = t0;
|
||||
const {
|
||||
exit
|
||||
} = useApp();
|
||||
let t1;
|
||||
let t2;
|
||||
if ($[0] !== exit) {
|
||||
t1 = () => {
|
||||
const timer = setTimeout(exit, 0);
|
||||
return () => clearTimeout(timer);
|
||||
};
|
||||
t2 = [exit];
|
||||
$[0] = exit;
|
||||
$[1] = t1;
|
||||
$[2] = t2;
|
||||
} else {
|
||||
t1 = $[1];
|
||||
t2 = $[2];
|
||||
}
|
||||
useLayoutEffect(t1, t2);
|
||||
let t3;
|
||||
if ($[3] !== children) {
|
||||
t3 = <>{children}</>;
|
||||
$[3] = children;
|
||||
$[4] = t3;
|
||||
} else {
|
||||
t3 = $[4];
|
||||
}
|
||||
return t3;
|
||||
function RenderOnceAndExit({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}): React.ReactNode {
|
||||
const { exit } = useApp()
|
||||
|
||||
// useLayoutEffect runs synchronously after React commits DOM mutations.
|
||||
// setTimeout(0) defers exit to allow Ink to flush output to the stream.
|
||||
useLayoutEffect(() => {
|
||||
const timer = setTimeout(exit, 0)
|
||||
return () => clearTimeout(timer)
|
||||
}, [exit])
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// DEC synchronized update markers used by terminals
|
||||
const SYNC_START = '\x1B[?2026h';
|
||||
const SYNC_END = '\x1B[?2026l';
|
||||
const SYNC_START = '\x1B[?2026h'
|
||||
const SYNC_END = '\x1B[?2026l'
|
||||
|
||||
/**
|
||||
* Extracts content from the first complete frame in Ink's output.
|
||||
@@ -60,56 +41,64 @@ const SYNC_END = '\x1B[?2026l';
|
||||
* update sequences ([?2026h ... [?2026l). We only want the first frame's content.
|
||||
*/
|
||||
function extractFirstFrame(output: string): string {
|
||||
const startIndex = output.indexOf(SYNC_START);
|
||||
if (startIndex === -1) return output;
|
||||
const contentStart = startIndex + SYNC_START.length;
|
||||
const endIndex = output.indexOf(SYNC_END, contentStart);
|
||||
if (endIndex === -1) return output;
|
||||
return output.slice(contentStart, endIndex);
|
||||
const startIndex = output.indexOf(SYNC_START)
|
||||
if (startIndex === -1) return output
|
||||
|
||||
const contentStart = startIndex + SYNC_START.length
|
||||
const endIndex = output.indexOf(SYNC_END, contentStart)
|
||||
if (endIndex === -1) return output
|
||||
|
||||
return output.slice(contentStart, endIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a React node to a string with ANSI escape codes (for terminal output).
|
||||
*/
|
||||
export function renderToAnsiString(node: React.ReactNode, columns?: number): Promise<string> {
|
||||
export function renderToAnsiString(
|
||||
node: React.ReactNode,
|
||||
columns?: number,
|
||||
): Promise<string> {
|
||||
return new Promise(async resolve => {
|
||||
let output = '';
|
||||
let output = ''
|
||||
|
||||
// Capture all writes. Set .columns so Ink (ink.tsx:~165) picks up a
|
||||
// chosen width instead of PassThrough's undefined → 80 fallback —
|
||||
// useful for rendering at terminal width for file dumps that should
|
||||
// match what the user sees on screen.
|
||||
const stream = new PassThrough();
|
||||
const stream = new PassThrough()
|
||||
if (columns !== undefined) {
|
||||
;
|
||||
(stream as unknown as {
|
||||
columns: number;
|
||||
}).columns = columns;
|
||||
;(stream as unknown as { columns: number }).columns = columns
|
||||
}
|
||||
stream.on('data', chunk => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
output += chunk.toString()
|
||||
})
|
||||
|
||||
// Render the component wrapped in RenderOnceAndExit
|
||||
// Non-TTY stdout (PassThrough) gives full-frame output instead of diffs
|
||||
const instance = await render(<RenderOnceAndExit>{node}</RenderOnceAndExit>, {
|
||||
stdout: stream as unknown as NodeJS.WriteStream,
|
||||
patchConsole: false
|
||||
});
|
||||
const instance = await render(
|
||||
<RenderOnceAndExit>{node}</RenderOnceAndExit>,
|
||||
{
|
||||
stdout: stream as unknown as NodeJS.WriteStream,
|
||||
patchConsole: false,
|
||||
},
|
||||
)
|
||||
|
||||
// Wait for the component to exit naturally
|
||||
await instance.waitUntilExit();
|
||||
await instance.waitUntilExit()
|
||||
|
||||
// Extract only the first frame's content to avoid duplication
|
||||
// (Ink outputs multiple frames in non-TTY mode)
|
||||
await resolve(extractFirstFrame(output));
|
||||
});
|
||||
await resolve(extractFirstFrame(output))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a React node to a plain text string (ANSI codes stripped).
|
||||
*/
|
||||
export async function renderToString(node: React.ReactNode, columns?: number): Promise<string> {
|
||||
const output = await renderToAnsiString(node, columns);
|
||||
return stripAnsi(output);
|
||||
export async function renderToString(
|
||||
node: React.ReactNode,
|
||||
columns?: number,
|
||||
): Promise<string> {
|
||||
const output = await renderToAnsiString(node, columns)
|
||||
return stripAnsi(output)
|
||||
}
|
||||
|
||||
@@ -1,361 +1,472 @@
|
||||
import chalk from 'chalk';
|
||||
import figures from 'figures';
|
||||
import * as React from 'react';
|
||||
import { color, Text } from '../ink.js';
|
||||
import type { MCPServerConnection } from '../services/mcp/types.js';
|
||||
import { getAccountInformation, isClaudeAISubscriber } from './auth.js';
|
||||
import { getLargeMemoryFiles, getMemoryFiles, MAX_MEMORY_CHARACTER_COUNT } from './claudemd.js';
|
||||
import { getDoctorDiagnostic } from './doctorDiagnostic.js';
|
||||
import { getAWSRegion, getDefaultVertexRegion, isEnvTruthy } from './envUtils.js';
|
||||
import { getDisplayPath } from './file.js';
|
||||
import { formatNumber } from './format.js';
|
||||
import { getIdeClientName, type IDEExtensionInstallationStatus, isJetBrainsIde, toIDEDisplayName } from './ide.js';
|
||||
import { getClaudeAiUserDefaultModelDescription, modelDisplayString } from './model/model.js';
|
||||
import { getAPIProvider } from './model/providers.js';
|
||||
import { getMTLSConfig } from './mtls.js';
|
||||
import { checkInstall } from './nativeInstaller/index.js';
|
||||
import { getProxyUrl } from './proxy.js';
|
||||
import { SandboxManager } from './sandbox/sandbox-adapter.js';
|
||||
import { getSettingsWithAllErrors } from './settings/allErrors.js';
|
||||
import { getEnabledSettingSources, getSettingSourceDisplayNameCapitalized } from './settings/constants.js';
|
||||
import { getManagedFileSettingsPresence, getPolicySettingsOrigin, getSettingsForSource } from './settings/settings.js';
|
||||
import type { ThemeName } from './theme.js';
|
||||
import chalk from 'chalk'
|
||||
import figures from 'figures'
|
||||
import * as React from 'react'
|
||||
import { color, Text } from '../ink.js'
|
||||
import type { MCPServerConnection } from '../services/mcp/types.js'
|
||||
import { getAccountInformation, isClaudeAISubscriber } from './auth.js'
|
||||
import {
|
||||
getLargeMemoryFiles,
|
||||
getMemoryFiles,
|
||||
MAX_MEMORY_CHARACTER_COUNT,
|
||||
} from './claudemd.js'
|
||||
import { getDoctorDiagnostic } from './doctorDiagnostic.js'
|
||||
import {
|
||||
getAWSRegion,
|
||||
getDefaultVertexRegion,
|
||||
isEnvTruthy,
|
||||
} from './envUtils.js'
|
||||
import { getDisplayPath } from './file.js'
|
||||
import { formatNumber } from './format.js'
|
||||
import {
|
||||
getIdeClientName,
|
||||
type IDEExtensionInstallationStatus,
|
||||
isJetBrainsIde,
|
||||
toIDEDisplayName,
|
||||
} from './ide.js'
|
||||
import {
|
||||
getClaudeAiUserDefaultModelDescription,
|
||||
modelDisplayString,
|
||||
} from './model/model.js'
|
||||
import { getAPIProvider } from './model/providers.js'
|
||||
import { getMTLSConfig } from './mtls.js'
|
||||
import { checkInstall } from './nativeInstaller/index.js'
|
||||
import { getProxyUrl } from './proxy.js'
|
||||
import { SandboxManager } from './sandbox/sandbox-adapter.js'
|
||||
import { getSettingsWithAllErrors } from './settings/allErrors.js'
|
||||
import {
|
||||
getEnabledSettingSources,
|
||||
getSettingSourceDisplayNameCapitalized,
|
||||
} from './settings/constants.js'
|
||||
import {
|
||||
getManagedFileSettingsPresence,
|
||||
getPolicySettingsOrigin,
|
||||
getSettingsForSource,
|
||||
} from './settings/settings.js'
|
||||
import type { ThemeName } from './theme.js'
|
||||
|
||||
export type Property = {
|
||||
label?: string;
|
||||
value: React.ReactNode | Array<string>;
|
||||
};
|
||||
export type Diagnostic = React.ReactNode;
|
||||
export function buildSandboxProperties(): Property[] {
|
||||
if ((process.env.USER_TYPE) !== 'ant') {
|
||||
return [];
|
||||
}
|
||||
const isSandboxed = SandboxManager.isSandboxingEnabled();
|
||||
return [{
|
||||
label: 'Bash Sandbox',
|
||||
value: isSandboxed ? 'Enabled' : 'Disabled'
|
||||
}];
|
||||
label?: string
|
||||
value: React.ReactNode | Array<string>
|
||||
}
|
||||
export function buildIDEProperties(mcpClients: MCPServerConnection[], ideInstallationStatus: IDEExtensionInstallationStatus | null = null, theme: ThemeName): Property[] {
|
||||
const ideClient = mcpClients?.find(client => client.name === 'ide');
|
||||
|
||||
export type Diagnostic = React.ReactNode
|
||||
|
||||
export function buildSandboxProperties(): Property[] {
|
||||
if (process.env.USER_TYPE !== 'ant') {
|
||||
return []
|
||||
}
|
||||
|
||||
const isSandboxed = SandboxManager.isSandboxingEnabled()
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Bash Sandbox',
|
||||
value: isSandboxed ? 'Enabled' : 'Disabled',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function buildIDEProperties(
|
||||
mcpClients: MCPServerConnection[],
|
||||
ideInstallationStatus: IDEExtensionInstallationStatus | null = null,
|
||||
theme: ThemeName,
|
||||
): Property[] {
|
||||
const ideClient = mcpClients?.find(client => client.name === 'ide')
|
||||
|
||||
if (ideInstallationStatus) {
|
||||
const ideName = toIDEDisplayName(ideInstallationStatus.ideType);
|
||||
const pluginOrExtension = isJetBrainsIde(ideInstallationStatus.ideType) ? 'plugin' : 'extension';
|
||||
const ideName = toIDEDisplayName(ideInstallationStatus.ideType)
|
||||
const pluginOrExtension = isJetBrainsIde(ideInstallationStatus.ideType)
|
||||
? 'plugin'
|
||||
: 'extension'
|
||||
|
||||
if (ideInstallationStatus.error) {
|
||||
return [{
|
||||
label: 'IDE',
|
||||
value: <Text>
|
||||
return [
|
||||
{
|
||||
label: 'IDE',
|
||||
value: (
|
||||
<Text>
|
||||
{color('error', theme)(figures.cross)} Error installing {ideName}{' '}
|
||||
{pluginOrExtension}: {ideInstallationStatus.error}
|
||||
{'\n'}Please restart your IDE and try again.
|
||||
</Text>
|
||||
}];
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (ideInstallationStatus.installed) {
|
||||
if (ideClient && ideClient.type === 'connected') {
|
||||
if (ideInstallationStatus.installedVersion !== ideClient.serverInfo?.version) {
|
||||
return [{
|
||||
label: 'IDE',
|
||||
value: `Connected to ${ideName} ${pluginOrExtension} version ${ideInstallationStatus.installedVersion} (server version: ${ideClient.serverInfo?.version})`
|
||||
}];
|
||||
if (
|
||||
ideInstallationStatus.installedVersion !==
|
||||
ideClient.serverInfo?.version
|
||||
) {
|
||||
return [
|
||||
{
|
||||
label: 'IDE',
|
||||
value: `Connected to ${ideName} ${pluginOrExtension} version ${ideInstallationStatus.installedVersion} (server version: ${ideClient.serverInfo?.version})`,
|
||||
},
|
||||
]
|
||||
} else {
|
||||
return [{
|
||||
label: 'IDE',
|
||||
value: `Connected to ${ideName} ${pluginOrExtension} version ${ideInstallationStatus.installedVersion}`
|
||||
}];
|
||||
return [
|
||||
{
|
||||
label: 'IDE',
|
||||
value: `Connected to ${ideName} ${pluginOrExtension} version ${ideInstallationStatus.installedVersion}`,
|
||||
},
|
||||
]
|
||||
}
|
||||
} else {
|
||||
return [{
|
||||
label: 'IDE',
|
||||
value: `Installed ${ideName} ${pluginOrExtension}`
|
||||
}];
|
||||
return [
|
||||
{
|
||||
label: 'IDE',
|
||||
value: `Installed ${ideName} ${pluginOrExtension}`,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
} else if (ideClient) {
|
||||
const ideName = getIdeClientName(ideClient) ?? 'IDE';
|
||||
const ideName = getIdeClientName(ideClient) ?? 'IDE'
|
||||
if (ideClient.type === 'connected') {
|
||||
return [{
|
||||
label: 'IDE',
|
||||
value: `Connected to ${ideName} extension`
|
||||
}];
|
||||
return [
|
||||
{
|
||||
label: 'IDE',
|
||||
value: `Connected to ${ideName} extension`,
|
||||
},
|
||||
]
|
||||
} else {
|
||||
return [{
|
||||
label: 'IDE',
|
||||
value: `${color('error', theme)(figures.cross)} Not connected to ${ideName}`
|
||||
}];
|
||||
return [
|
||||
{
|
||||
label: 'IDE',
|
||||
value: `${color('error', theme)(figures.cross)} Not connected to ${ideName}`,
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
return [];
|
||||
|
||||
return []
|
||||
}
|
||||
export function buildMcpProperties(clients: MCPServerConnection[] = [], theme: ThemeName): Property[] {
|
||||
const servers = clients.filter(client => client.name !== 'ide');
|
||||
|
||||
export function buildMcpProperties(
|
||||
clients: MCPServerConnection[] = [],
|
||||
theme: ThemeName,
|
||||
): Property[] {
|
||||
const servers = clients.filter(client => client.name !== 'ide')
|
||||
if (!servers.length) {
|
||||
return [];
|
||||
return []
|
||||
}
|
||||
|
||||
// Summary instead of a full server list — 20+ servers wrapped onto many
|
||||
// rows, dominating the Status pane. Show counts by state + /mcp hint.
|
||||
const byState = {
|
||||
connected: 0,
|
||||
pending: 0,
|
||||
needsAuth: 0,
|
||||
failed: 0
|
||||
};
|
||||
const byState = { connected: 0, pending: 0, needsAuth: 0, failed: 0 }
|
||||
for (const s of servers) {
|
||||
if (s.type === 'connected') byState.connected++;else if (s.type === 'pending') byState.pending++;else if (s.type === 'needs-auth') byState.needsAuth++;else byState.failed++;
|
||||
if (s.type === 'connected') byState.connected++
|
||||
else if (s.type === 'pending') byState.pending++
|
||||
else if (s.type === 'needs-auth') byState.needsAuth++
|
||||
else byState.failed++
|
||||
}
|
||||
const parts: string[] = [];
|
||||
if (byState.connected) parts.push(color('success', theme)(`${byState.connected} connected`));
|
||||
if (byState.needsAuth) parts.push(color('warning', theme)(`${byState.needsAuth} need auth`));
|
||||
if (byState.pending) parts.push(color('inactive', theme)(`${byState.pending} pending`));
|
||||
if (byState.failed) parts.push(color('error', theme)(`${byState.failed} failed`));
|
||||
return [{
|
||||
label: 'MCP servers',
|
||||
value: `${parts.join(', ')} ${color('inactive', theme)('· /mcp')}`
|
||||
}];
|
||||
const parts: string[] = []
|
||||
if (byState.connected)
|
||||
parts.push(color('success', theme)(`${byState.connected} connected`))
|
||||
if (byState.needsAuth)
|
||||
parts.push(color('warning', theme)(`${byState.needsAuth} need auth`))
|
||||
if (byState.pending)
|
||||
parts.push(color('inactive', theme)(`${byState.pending} pending`))
|
||||
if (byState.failed)
|
||||
parts.push(color('error', theme)(`${byState.failed} failed`))
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'MCP servers',
|
||||
value: `${parts.join(', ')} ${color('inactive', theme)('· /mcp')}`,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export async function buildMemoryDiagnostics(): Promise<Diagnostic[]> {
|
||||
const files = await getMemoryFiles();
|
||||
const largeFiles = getLargeMemoryFiles(files);
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
const files = await getMemoryFiles()
|
||||
const largeFiles = getLargeMemoryFiles(files)
|
||||
|
||||
const diagnostics: Diagnostic[] = []
|
||||
|
||||
largeFiles.forEach(file => {
|
||||
const displayPath = getDisplayPath(file.path);
|
||||
diagnostics.push(`Large ${displayPath} will impact performance (${formatNumber(file.content.length)} chars > ${formatNumber(MAX_MEMORY_CHARACTER_COUNT)})`);
|
||||
});
|
||||
return diagnostics;
|
||||
const displayPath = getDisplayPath(file.path)
|
||||
diagnostics.push(
|
||||
`Large ${displayPath} will impact performance (${formatNumber(file.content.length)} chars > ${formatNumber(MAX_MEMORY_CHARACTER_COUNT)})`,
|
||||
)
|
||||
})
|
||||
|
||||
return diagnostics
|
||||
}
|
||||
|
||||
export function buildSettingSourcesProperties(): Property[] {
|
||||
const enabledSources = getEnabledSettingSources();
|
||||
const enabledSources = getEnabledSettingSources()
|
||||
|
||||
// Filter to only sources that actually have settings loaded
|
||||
const sourcesWithSettings = enabledSources.filter(source => {
|
||||
const settings = getSettingsForSource(source);
|
||||
return settings !== null && Object.keys(settings).length > 0;
|
||||
});
|
||||
const settings = getSettingsForSource(source)
|
||||
return settings !== null && Object.keys(settings).length > 0
|
||||
})
|
||||
|
||||
// Map internal names to user-friendly names
|
||||
// For policySettings, distinguish between remote and local (or skip if neither exists)
|
||||
const sourceNames = sourcesWithSettings.map(source => {
|
||||
if (source === 'policySettings') {
|
||||
const origin = getPolicySettingsOrigin();
|
||||
if (origin === null) {
|
||||
return null; // Skip - no policy settings exist
|
||||
}
|
||||
switch (origin) {
|
||||
case 'remote':
|
||||
return 'Enterprise managed settings (remote)';
|
||||
case 'plist':
|
||||
return 'Enterprise managed settings (plist)';
|
||||
case 'hklm':
|
||||
return 'Enterprise managed settings (HKLM)';
|
||||
case 'file':
|
||||
{
|
||||
const {
|
||||
hasBase,
|
||||
hasDropIns
|
||||
} = getManagedFileSettingsPresence();
|
||||
const sourceNames = sourcesWithSettings
|
||||
.map(source => {
|
||||
if (source === 'policySettings') {
|
||||
const origin = getPolicySettingsOrigin()
|
||||
if (origin === null) {
|
||||
return null // Skip - no policy settings exist
|
||||
}
|
||||
switch (origin) {
|
||||
case 'remote':
|
||||
return 'Enterprise managed settings (remote)'
|
||||
case 'plist':
|
||||
return 'Enterprise managed settings (plist)'
|
||||
case 'hklm':
|
||||
return 'Enterprise managed settings (HKLM)'
|
||||
case 'file': {
|
||||
const { hasBase, hasDropIns } = getManagedFileSettingsPresence()
|
||||
if (hasBase && hasDropIns) {
|
||||
return 'Enterprise managed settings (file + drop-ins)';
|
||||
return 'Enterprise managed settings (file + drop-ins)'
|
||||
}
|
||||
if (hasDropIns) {
|
||||
return 'Enterprise managed settings (drop-ins)';
|
||||
return 'Enterprise managed settings (drop-ins)'
|
||||
}
|
||||
return 'Enterprise managed settings (file)';
|
||||
return 'Enterprise managed settings (file)'
|
||||
}
|
||||
case 'hkcu':
|
||||
return 'Enterprise managed settings (HKCU)';
|
||||
case 'hkcu':
|
||||
return 'Enterprise managed settings (HKCU)'
|
||||
}
|
||||
}
|
||||
}
|
||||
return getSettingSourceDisplayNameCapitalized(source);
|
||||
}).filter((name): name is string => name !== null);
|
||||
return [{
|
||||
label: 'Setting sources',
|
||||
value: sourceNames
|
||||
}];
|
||||
return getSettingSourceDisplayNameCapitalized(source)
|
||||
})
|
||||
.filter((name): name is string => name !== null)
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'Setting sources',
|
||||
value: sourceNames,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export async function buildInstallationDiagnostics(): Promise<Diagnostic[]> {
|
||||
const installWarnings = await checkInstall();
|
||||
return installWarnings.map(warning => warning.message);
|
||||
const installWarnings = await checkInstall()
|
||||
return installWarnings.map(warning => warning.message)
|
||||
}
|
||||
export async function buildInstallationHealthDiagnostics(): Promise<Diagnostic[]> {
|
||||
const diagnostic = await getDoctorDiagnostic();
|
||||
const items: Diagnostic[] = [];
|
||||
const {
|
||||
errors: validationErrors
|
||||
} = getSettingsWithAllErrors();
|
||||
|
||||
export async function buildInstallationHealthDiagnostics(): Promise<
|
||||
Diagnostic[]
|
||||
> {
|
||||
const diagnostic = await getDoctorDiagnostic()
|
||||
const items: Diagnostic[] = []
|
||||
|
||||
const { errors: validationErrors } = getSettingsWithAllErrors()
|
||||
if (validationErrors.length > 0) {
|
||||
const invalidFiles = Array.from(new Set(validationErrors.map(error => error.file)));
|
||||
const fileList = invalidFiles.join(', ');
|
||||
items.push(`Found invalid settings files: ${fileList}. They will be ignored.`);
|
||||
const invalidFiles = Array.from(
|
||||
new Set(validationErrors.map(error => error.file)),
|
||||
)
|
||||
const fileList = invalidFiles.join(', ')
|
||||
|
||||
items.push(
|
||||
`Found invalid settings files: ${fileList}. They will be ignored.`,
|
||||
)
|
||||
}
|
||||
|
||||
// Add warnings from doctor diagnostic (includes leftover installations, config mismatches, etc.)
|
||||
diagnostic.warnings.forEach(warning => {
|
||||
items.push(warning.issue);
|
||||
});
|
||||
items.push(warning.issue)
|
||||
})
|
||||
|
||||
if (diagnostic.hasUpdatePermissions === false) {
|
||||
items.push('No write permissions for auto-updates (requires sudo)');
|
||||
items.push('No write permissions for auto-updates (requires sudo)')
|
||||
}
|
||||
return items;
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
export function buildAccountProperties(): Property[] {
|
||||
const accountInfo = getAccountInformation();
|
||||
const accountInfo = getAccountInformation()
|
||||
if (!accountInfo) {
|
||||
return [];
|
||||
return []
|
||||
}
|
||||
const properties: Property[] = [];
|
||||
|
||||
const properties: Property[] = []
|
||||
|
||||
if (accountInfo.subscription) {
|
||||
properties.push({
|
||||
label: 'Login method',
|
||||
value: `${accountInfo.subscription} Account`
|
||||
});
|
||||
value: `${accountInfo.subscription} Account`,
|
||||
})
|
||||
}
|
||||
|
||||
if (accountInfo.tokenSource) {
|
||||
properties.push({
|
||||
label: 'Auth token',
|
||||
value: accountInfo.tokenSource
|
||||
});
|
||||
value: accountInfo.tokenSource,
|
||||
})
|
||||
}
|
||||
|
||||
if (accountInfo.apiKeySource) {
|
||||
properties.push({
|
||||
label: 'API key',
|
||||
value: accountInfo.apiKeySource
|
||||
});
|
||||
value: accountInfo.apiKeySource,
|
||||
})
|
||||
}
|
||||
|
||||
// Hide sensitive account info in demo mode
|
||||
if (accountInfo.organization && !process.env.IS_DEMO) {
|
||||
properties.push({
|
||||
label: 'Organization',
|
||||
value: accountInfo.organization
|
||||
});
|
||||
value: accountInfo.organization,
|
||||
})
|
||||
}
|
||||
if (accountInfo.email && !process.env.IS_DEMO) {
|
||||
properties.push({
|
||||
label: 'Email',
|
||||
value: accountInfo.email
|
||||
});
|
||||
value: accountInfo.email,
|
||||
})
|
||||
}
|
||||
return properties;
|
||||
|
||||
return properties
|
||||
}
|
||||
|
||||
export function buildAPIProviderProperties(): Property[] {
|
||||
const apiProvider = getAPIProvider();
|
||||
const properties: Property[] = [];
|
||||
const apiProvider = getAPIProvider()
|
||||
|
||||
const properties: Property[] = []
|
||||
|
||||
if (apiProvider !== 'firstParty') {
|
||||
const providerLabel = {
|
||||
bedrock: 'AWS Bedrock',
|
||||
vertex: 'Google Vertex AI',
|
||||
foundry: 'Microsoft Foundry'
|
||||
}[apiProvider];
|
||||
foundry: 'Microsoft Foundry',
|
||||
}[apiProvider]
|
||||
|
||||
properties.push({
|
||||
label: 'API provider',
|
||||
value: providerLabel
|
||||
});
|
||||
value: providerLabel,
|
||||
})
|
||||
}
|
||||
|
||||
if (apiProvider === 'firstParty') {
|
||||
const anthropicBaseUrl = process.env.ANTHROPIC_BASE_URL;
|
||||
const anthropicBaseUrl = process.env.ANTHROPIC_BASE_URL
|
||||
if (anthropicBaseUrl) {
|
||||
properties.push({
|
||||
label: 'Anthropic base URL',
|
||||
value: anthropicBaseUrl
|
||||
});
|
||||
value: anthropicBaseUrl,
|
||||
})
|
||||
}
|
||||
} else if (apiProvider === 'bedrock') {
|
||||
const bedrockBaseUrl = process.env.BEDROCK_BASE_URL;
|
||||
const bedrockBaseUrl = process.env.BEDROCK_BASE_URL
|
||||
if (bedrockBaseUrl) {
|
||||
properties.push({
|
||||
label: 'Bedrock base URL',
|
||||
value: bedrockBaseUrl
|
||||
});
|
||||
value: bedrockBaseUrl,
|
||||
})
|
||||
}
|
||||
|
||||
properties.push({
|
||||
label: 'AWS region',
|
||||
value: getAWSRegion()
|
||||
});
|
||||
value: getAWSRegion(),
|
||||
})
|
||||
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_SKIP_BEDROCK_AUTH)) {
|
||||
properties.push({
|
||||
value: 'AWS auth skipped'
|
||||
});
|
||||
value: 'AWS auth skipped',
|
||||
})
|
||||
}
|
||||
} else if (apiProvider === 'vertex') {
|
||||
const vertexBaseUrl = process.env.VERTEX_BASE_URL;
|
||||
const vertexBaseUrl = process.env.VERTEX_BASE_URL
|
||||
if (vertexBaseUrl) {
|
||||
properties.push({
|
||||
label: 'Vertex base URL',
|
||||
value: vertexBaseUrl
|
||||
});
|
||||
value: vertexBaseUrl,
|
||||
})
|
||||
}
|
||||
const gcpProject = process.env.ANTHROPIC_VERTEX_PROJECT_ID;
|
||||
|
||||
const gcpProject = process.env.ANTHROPIC_VERTEX_PROJECT_ID
|
||||
if (gcpProject) {
|
||||
properties.push({
|
||||
label: 'GCP project',
|
||||
value: gcpProject
|
||||
});
|
||||
value: gcpProject,
|
||||
})
|
||||
}
|
||||
|
||||
properties.push({
|
||||
label: 'Default region',
|
||||
value: getDefaultVertexRegion()
|
||||
});
|
||||
value: getDefaultVertexRegion(),
|
||||
})
|
||||
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_SKIP_VERTEX_AUTH)) {
|
||||
properties.push({
|
||||
value: 'GCP auth skipped'
|
||||
});
|
||||
value: 'GCP auth skipped',
|
||||
})
|
||||
}
|
||||
} else if (apiProvider === 'foundry') {
|
||||
const foundryBaseUrl = process.env.ANTHROPIC_FOUNDRY_BASE_URL;
|
||||
const foundryBaseUrl = process.env.ANTHROPIC_FOUNDRY_BASE_URL
|
||||
if (foundryBaseUrl) {
|
||||
properties.push({
|
||||
label: 'Microsoft Foundry base URL',
|
||||
value: foundryBaseUrl
|
||||
});
|
||||
value: foundryBaseUrl,
|
||||
})
|
||||
}
|
||||
const foundryResource = process.env.ANTHROPIC_FOUNDRY_RESOURCE;
|
||||
|
||||
const foundryResource = process.env.ANTHROPIC_FOUNDRY_RESOURCE
|
||||
if (foundryResource) {
|
||||
properties.push({
|
||||
label: 'Microsoft Foundry resource',
|
||||
value: foundryResource
|
||||
});
|
||||
value: foundryResource,
|
||||
})
|
||||
}
|
||||
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_SKIP_FOUNDRY_AUTH)) {
|
||||
properties.push({
|
||||
value: 'Microsoft Foundry auth skipped'
|
||||
});
|
||||
value: 'Microsoft Foundry auth skipped',
|
||||
})
|
||||
}
|
||||
}
|
||||
const proxyUrl = getProxyUrl();
|
||||
|
||||
const proxyUrl = getProxyUrl()
|
||||
if (proxyUrl) {
|
||||
properties.push({
|
||||
label: 'Proxy',
|
||||
value: proxyUrl
|
||||
});
|
||||
value: proxyUrl,
|
||||
})
|
||||
}
|
||||
const mtlsConfig = getMTLSConfig();
|
||||
|
||||
const mtlsConfig = getMTLSConfig()
|
||||
if (process.env.NODE_EXTRA_CA_CERTS) {
|
||||
properties.push({
|
||||
label: 'Additional CA cert(s)',
|
||||
value: process.env.NODE_EXTRA_CA_CERTS
|
||||
});
|
||||
value: process.env.NODE_EXTRA_CA_CERTS,
|
||||
})
|
||||
}
|
||||
if (mtlsConfig) {
|
||||
if (mtlsConfig.cert && process.env.CLAUDE_CODE_CLIENT_CERT) {
|
||||
properties.push({
|
||||
label: 'mTLS client cert',
|
||||
value: process.env.CLAUDE_CODE_CLIENT_CERT
|
||||
});
|
||||
value: process.env.CLAUDE_CODE_CLIENT_CERT,
|
||||
})
|
||||
}
|
||||
|
||||
if (mtlsConfig.key && process.env.CLAUDE_CODE_CLIENT_KEY) {
|
||||
properties.push({
|
||||
label: 'mTLS client key',
|
||||
value: process.env.CLAUDE_CODE_CLIENT_KEY
|
||||
});
|
||||
value: process.env.CLAUDE_CODE_CLIENT_KEY,
|
||||
})
|
||||
}
|
||||
}
|
||||
return properties;
|
||||
|
||||
return properties
|
||||
}
|
||||
|
||||
export function getModelDisplayLabel(mainLoopModel: string | null): string {
|
||||
let modelLabel = modelDisplayString(mainLoopModel);
|
||||
let modelLabel = modelDisplayString(mainLoopModel)
|
||||
|
||||
if (mainLoopModel === null && isClaudeAISubscriber()) {
|
||||
const description = getClaudeAiUserDefaultModelDescription();
|
||||
modelLabel = `${chalk.bold('Default')} ${description}`;
|
||||
const description = getClaudeAiUserDefaultModelDescription()
|
||||
|
||||
modelLabel = `${chalk.bold('Default')} ${description}`
|
||||
}
|
||||
return modelLabel;
|
||||
|
||||
return modelLabel
|
||||
}
|
||||
|
||||
@@ -1,31 +1,49 @@
|
||||
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
||||
import { Box, Text } from '../ink.js';
|
||||
import * as React from 'react';
|
||||
import { getLargeMemoryFiles, MAX_MEMORY_CHARACTER_COUNT, type MemoryFileInfo } from './claudemd.js';
|
||||
import figures from 'figures';
|
||||
import { getCwd } from './cwd.js';
|
||||
import { relative } from 'path';
|
||||
import { formatNumber } from './format.js';
|
||||
import type { getGlobalConfig } from './config.js';
|
||||
import { getAnthropicApiKeyWithSource, getApiKeyFromConfigOrMacOSKeychain, getAuthTokenSource, isClaudeAISubscriber } from './auth.js';
|
||||
import type { AgentDefinitionsResult } from '../tools/AgentTool/loadAgentsDir.js';
|
||||
import { getAgentDescriptionsTotalTokens, AGENT_DESCRIPTIONS_THRESHOLD } from './statusNoticeHelpers.js';
|
||||
import { isSupportedJetBrainsTerminal, toIDEDisplayName, getTerminalIdeType } from './ide.js';
|
||||
import { isJetBrainsPluginInstalledCachedSync } from './jetbrains.js';
|
||||
import { Box, Text } from '../ink.js'
|
||||
import * as React from 'react'
|
||||
import {
|
||||
getLargeMemoryFiles,
|
||||
MAX_MEMORY_CHARACTER_COUNT,
|
||||
type MemoryFileInfo,
|
||||
} from './claudemd.js'
|
||||
import figures from 'figures'
|
||||
import { getCwd } from './cwd.js'
|
||||
import { relative } from 'path'
|
||||
import { formatNumber } from './format.js'
|
||||
import type { getGlobalConfig } from './config.js'
|
||||
import {
|
||||
getAnthropicApiKeyWithSource,
|
||||
getApiKeyFromConfigOrMacOSKeychain,
|
||||
getAuthTokenSource,
|
||||
isClaudeAISubscriber,
|
||||
} from './auth.js'
|
||||
import type { AgentDefinitionsResult } from '../tools/AgentTool/loadAgentsDir.js'
|
||||
import {
|
||||
getAgentDescriptionsTotalTokens,
|
||||
AGENT_DESCRIPTIONS_THRESHOLD,
|
||||
} from './statusNoticeHelpers.js'
|
||||
import {
|
||||
isSupportedJetBrainsTerminal,
|
||||
toIDEDisplayName,
|
||||
getTerminalIdeType,
|
||||
} from './ide.js'
|
||||
import { isJetBrainsPluginInstalledCachedSync } from './jetbrains.js'
|
||||
|
||||
// Types
|
||||
export type StatusNoticeType = 'warning' | 'info';
|
||||
export type StatusNoticeType = 'warning' | 'info'
|
||||
|
||||
export type StatusNoticeContext = {
|
||||
config: ReturnType<typeof getGlobalConfig>;
|
||||
agentDefinitions?: AgentDefinitionsResult;
|
||||
memoryFiles: MemoryFileInfo[];
|
||||
};
|
||||
config: ReturnType<typeof getGlobalConfig>
|
||||
agentDefinitions?: AgentDefinitionsResult
|
||||
memoryFiles: MemoryFileInfo[]
|
||||
}
|
||||
|
||||
export type StatusNoticeDefinition = {
|
||||
id: string;
|
||||
type: StatusNoticeType;
|
||||
isActive: (context: StatusNoticeContext) => boolean;
|
||||
render: (context: StatusNoticeContext) => React.ReactNode;
|
||||
};
|
||||
id: string
|
||||
type: StatusNoticeType
|
||||
isActive: (context: StatusNoticeContext) => boolean
|
||||
render: (context: StatusNoticeContext) => React.ReactNode
|
||||
}
|
||||
|
||||
// Individual notice definitions
|
||||
const largeMemoryFilesNotice: StatusNoticeDefinition = {
|
||||
@@ -33,11 +51,16 @@ const largeMemoryFilesNotice: StatusNoticeDefinition = {
|
||||
type: 'warning',
|
||||
isActive: ctx => getLargeMemoryFiles(ctx.memoryFiles).length > 0,
|
||||
render: ctx => {
|
||||
const largeMemoryFiles = getLargeMemoryFiles(ctx.memoryFiles);
|
||||
return <>
|
||||
const largeMemoryFiles = getLargeMemoryFiles(ctx.memoryFiles)
|
||||
return (
|
||||
<>
|
||||
{largeMemoryFiles.map(file => {
|
||||
const displayPath = file.path.startsWith(getCwd()) ? relative(getCwd(), file.path) : file.path;
|
||||
return <Box key={file.path} flexDirection="row">
|
||||
const displayPath = file.path.startsWith(getCwd())
|
||||
? relative(getCwd(), file.path)
|
||||
: file.path
|
||||
|
||||
return (
|
||||
<Box key={file.path} flexDirection="row">
|
||||
<Text color="warning">{figures.warning}</Text>
|
||||
<Text color="warning">
|
||||
Large <Text bold>{displayPath}</Text> will impact performance (
|
||||
@@ -45,76 +68,92 @@ const largeMemoryFilesNotice: StatusNoticeDefinition = {
|
||||
{formatNumber(MAX_MEMORY_CHARACTER_COUNT)})
|
||||
<Text dimColor> · /memory to edit</Text>
|
||||
</Text>
|
||||
</Box>;
|
||||
})}
|
||||
</>;
|
||||
}
|
||||
};
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
const claudeAiSubscriberExternalTokenNotice: StatusNoticeDefinition = {
|
||||
id: 'claude-ai-external-token',
|
||||
type: 'warning',
|
||||
isActive: () => {
|
||||
const authTokenInfo = getAuthTokenSource();
|
||||
return isClaudeAISubscriber() && (authTokenInfo.source === 'ANTHROPIC_AUTH_TOKEN' || authTokenInfo.source === 'apiKeyHelper');
|
||||
const authTokenInfo = getAuthTokenSource()
|
||||
return (
|
||||
isClaudeAISubscriber() &&
|
||||
(authTokenInfo.source === 'ANTHROPIC_AUTH_TOKEN' ||
|
||||
authTokenInfo.source === 'apiKeyHelper')
|
||||
)
|
||||
},
|
||||
render: () => {
|
||||
const authTokenInfo = getAuthTokenSource();
|
||||
return <Box flexDirection="row" marginTop={1}>
|
||||
const authTokenInfo = getAuthTokenSource()
|
||||
return (
|
||||
<Box flexDirection="row" marginTop={1}>
|
||||
<Text color="warning">{figures.warning}</Text>
|
||||
<Text color="warning">
|
||||
Auth conflict: Using {authTokenInfo.source} instead of Claude account
|
||||
subscription token. Either unset {authTokenInfo.source}, or run
|
||||
`claude /logout`.
|
||||
</Text>
|
||||
</Box>;
|
||||
}
|
||||
};
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
const apiKeyConflictNotice: StatusNoticeDefinition = {
|
||||
id: 'api-key-conflict',
|
||||
type: 'warning',
|
||||
isActive: () => {
|
||||
const {
|
||||
source: apiKeySource
|
||||
} = getAnthropicApiKeyWithSource({
|
||||
skipRetrievingKeyFromApiKeyHelper: true
|
||||
});
|
||||
return !!getApiKeyFromConfigOrMacOSKeychain() && (apiKeySource === 'ANTHROPIC_API_KEY' || apiKeySource === 'apiKeyHelper');
|
||||
const { source: apiKeySource } = getAnthropicApiKeyWithSource({
|
||||
skipRetrievingKeyFromApiKeyHelper: true,
|
||||
})
|
||||
return (
|
||||
!!getApiKeyFromConfigOrMacOSKeychain() &&
|
||||
(apiKeySource === 'ANTHROPIC_API_KEY' || apiKeySource === 'apiKeyHelper')
|
||||
)
|
||||
},
|
||||
render: () => {
|
||||
const {
|
||||
source: apiKeySource
|
||||
} = getAnthropicApiKeyWithSource({
|
||||
skipRetrievingKeyFromApiKeyHelper: true
|
||||
});
|
||||
return <Box flexDirection="row" marginTop={1}>
|
||||
const { source: apiKeySource } = getAnthropicApiKeyWithSource({
|
||||
skipRetrievingKeyFromApiKeyHelper: true,
|
||||
})
|
||||
return (
|
||||
<Box flexDirection="row" marginTop={1}>
|
||||
<Text color="warning">{figures.warning}</Text>
|
||||
<Text color="warning">
|
||||
Auth conflict: Using {apiKeySource} instead of Anthropic Console key.
|
||||
Either unset {apiKeySource}, or run `claude /logout`.
|
||||
</Text>
|
||||
</Box>;
|
||||
}
|
||||
};
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
const bothAuthMethodsNotice: StatusNoticeDefinition = {
|
||||
id: 'both-auth-methods',
|
||||
type: 'warning',
|
||||
isActive: () => {
|
||||
const {
|
||||
source: apiKeySource
|
||||
} = getAnthropicApiKeyWithSource({
|
||||
skipRetrievingKeyFromApiKeyHelper: true
|
||||
});
|
||||
const authTokenInfo = getAuthTokenSource();
|
||||
return apiKeySource !== 'none' && authTokenInfo.source !== 'none' && !(apiKeySource === 'apiKeyHelper' && authTokenInfo.source === 'apiKeyHelper');
|
||||
const { source: apiKeySource } = getAnthropicApiKeyWithSource({
|
||||
skipRetrievingKeyFromApiKeyHelper: true,
|
||||
})
|
||||
const authTokenInfo = getAuthTokenSource()
|
||||
return (
|
||||
apiKeySource !== 'none' &&
|
||||
authTokenInfo.source !== 'none' &&
|
||||
!(
|
||||
apiKeySource === 'apiKeyHelper' &&
|
||||
authTokenInfo.source === 'apiKeyHelper'
|
||||
)
|
||||
)
|
||||
},
|
||||
render: () => {
|
||||
const {
|
||||
source: apiKeySource
|
||||
} = getAnthropicApiKeyWithSource({
|
||||
skipRetrievingKeyFromApiKeyHelper: true
|
||||
});
|
||||
const authTokenInfo = getAuthTokenSource();
|
||||
return <Box flexDirection="column" marginTop={1}>
|
||||
const { source: apiKeySource } = getAnthropicApiKeyWithSource({
|
||||
skipRetrievingKeyFromApiKeyHelper: true,
|
||||
})
|
||||
const authTokenInfo = getAuthTokenSource()
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box flexDirection="row">
|
||||
<Text color="warning">{figures.warning}</Text>
|
||||
<Text color="warning">
|
||||
@@ -125,28 +164,43 @@ const bothAuthMethodsNotice: StatusNoticeDefinition = {
|
||||
<Box flexDirection="column" marginLeft={3}>
|
||||
<Text color="warning">
|
||||
· Trying to use{' '}
|
||||
{authTokenInfo.source === 'claude.ai' ? 'claude.ai' : authTokenInfo.source}
|
||||
{authTokenInfo.source === 'claude.ai'
|
||||
? 'claude.ai'
|
||||
: authTokenInfo.source}
|
||||
?{' '}
|
||||
{apiKeySource === 'ANTHROPIC_API_KEY' ? 'Unset the ANTHROPIC_API_KEY environment variable, or claude /logout then say "No" to the API key approval before login.' : apiKeySource === 'apiKeyHelper' ? 'Unset the apiKeyHelper setting.' : 'claude /logout'}
|
||||
{apiKeySource === 'ANTHROPIC_API_KEY'
|
||||
? 'Unset the ANTHROPIC_API_KEY environment variable, or claude /logout then say "No" to the API key approval before login.'
|
||||
: apiKeySource === 'apiKeyHelper'
|
||||
? 'Unset the apiKeyHelper setting.'
|
||||
: 'claude /logout'}
|
||||
</Text>
|
||||
<Text color="warning">
|
||||
· Trying to use {apiKeySource}?{' '}
|
||||
{authTokenInfo.source === 'claude.ai' ? 'claude /logout to sign out of claude.ai.' : `Unset the ${authTokenInfo.source} environment variable.`}
|
||||
{authTokenInfo.source === 'claude.ai'
|
||||
? 'claude /logout to sign out of claude.ai.'
|
||||
: `Unset the ${authTokenInfo.source} environment variable.`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>;
|
||||
}
|
||||
};
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
const largeAgentDescriptionsNotice: StatusNoticeDefinition = {
|
||||
id: 'large-agent-descriptions',
|
||||
type: 'warning',
|
||||
isActive: context => {
|
||||
const totalTokens = getAgentDescriptionsTotalTokens(context.agentDefinitions);
|
||||
return totalTokens > AGENT_DESCRIPTIONS_THRESHOLD;
|
||||
const totalTokens = getAgentDescriptionsTotalTokens(
|
||||
context.agentDefinitions,
|
||||
)
|
||||
return totalTokens > AGENT_DESCRIPTIONS_THRESHOLD
|
||||
},
|
||||
render: context => {
|
||||
const totalTokens = getAgentDescriptionsTotalTokens(context.agentDefinitions);
|
||||
return <Box flexDirection="row">
|
||||
const totalTokens = getAgentDescriptionsTotalTokens(
|
||||
context.agentDefinitions,
|
||||
)
|
||||
return (
|
||||
<Box flexDirection="row">
|
||||
<Text color="warning">{figures.warning}</Text>
|
||||
<Text color="warning">
|
||||
Large cumulative agent descriptions will impact performance (~
|
||||
@@ -154,44 +208,58 @@ const largeAgentDescriptionsNotice: StatusNoticeDefinition = {
|
||||
{formatNumber(AGENT_DESCRIPTIONS_THRESHOLD)})
|
||||
<Text dimColor> · /agents to manage</Text>
|
||||
</Text>
|
||||
</Box>;
|
||||
}
|
||||
};
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
const jetbrainsPluginNotice: StatusNoticeDefinition = {
|
||||
id: 'jetbrains-plugin-install',
|
||||
type: 'info',
|
||||
isActive: context => {
|
||||
// Only show if running in JetBrains built-in terminal
|
||||
if (!isSupportedJetBrainsTerminal()) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
// Don't show if auto-install is disabled
|
||||
const shouldAutoInstall = context.config.autoInstallIdeExtension ?? true;
|
||||
const shouldAutoInstall = context.config.autoInstallIdeExtension ?? true
|
||||
if (!shouldAutoInstall) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
// Check if plugin is already installed (cached to avoid repeated filesystem checks)
|
||||
const ideType = getTerminalIdeType();
|
||||
return ideType !== null && !isJetBrainsPluginInstalledCachedSync(ideType);
|
||||
const ideType = getTerminalIdeType()
|
||||
return ideType !== null && !isJetBrainsPluginInstalledCachedSync(ideType)
|
||||
},
|
||||
render: () => {
|
||||
const ideType = getTerminalIdeType();
|
||||
const ideName = toIDEDisplayName(ideType);
|
||||
return <Box flexDirection="row" gap={1} marginLeft={1}>
|
||||
const ideType = getTerminalIdeType()
|
||||
const ideName = toIDEDisplayName(ideType)
|
||||
return (
|
||||
<Box flexDirection="row" gap={1} marginLeft={1}>
|
||||
<Text color="ide">{figures.arrowUp}</Text>
|
||||
<Text>
|
||||
Install the <Text color="ide">{ideName}</Text> plugin from the
|
||||
JetBrains Marketplace:{' '}
|
||||
<Text bold>https://docs.claude.com/s/claude-code-jetbrains</Text>
|
||||
</Text>
|
||||
</Box>;
|
||||
}
|
||||
};
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
// All notice definitions
|
||||
export const statusNoticeDefinitions: StatusNoticeDefinition[] = [largeMemoryFilesNotice, largeAgentDescriptionsNotice, claudeAiSubscriberExternalTokenNotice, apiKeyConflictNotice, bothAuthMethodsNotice, jetbrainsPluginNotice];
|
||||
export const statusNoticeDefinitions: StatusNoticeDefinition[] = [
|
||||
largeMemoryFilesNotice,
|
||||
largeAgentDescriptionsNotice,
|
||||
claudeAiSubscriberExternalTokenNotice,
|
||||
apiKeyConflictNotice,
|
||||
bothAuthMethodsNotice,
|
||||
jetbrainsPluginNotice,
|
||||
]
|
||||
|
||||
// Helper functions for external use
|
||||
export function getActiveNotices(context: StatusNoticeContext): StatusNoticeDefinition[] {
|
||||
return statusNoticeDefinitions.filter(notice => notice.isActive(context));
|
||||
export function getActiveNotices(
|
||||
context: StatusNoticeContext,
|
||||
): StatusNoticeDefinition[] {
|
||||
return statusNoticeDefinitions.filter(notice => notice.isActive(context))
|
||||
}
|
||||
|
||||
@@ -1,379 +1,377 @@
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { type OptionWithDescription, Select } from '../../components/CustomSelect/index.js';
|
||||
import { Pane } from '../../components/design-system/Pane.js';
|
||||
import { Spinner } from '../../components/Spinner.js';
|
||||
import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js';
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
type OptionWithDescription,
|
||||
Select,
|
||||
} from '../../components/CustomSelect/index.js'
|
||||
import { Pane } from '../../components/design-system/Pane.js'
|
||||
import { Spinner } from '../../components/Spinner.js'
|
||||
import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'
|
||||
// eslint-disable-next-line custom-rules/prefer-use-keybindings -- enter to proceed through setup steps
|
||||
import { Box, Text, useInput } from '../../ink.js';
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js';
|
||||
import { detectPythonPackageManager, getPythonApiInstructions, installIt2, markIt2SetupComplete, type PythonPackageManager, setPreferTmuxOverIterm2, verifyIt2Setup } from './backends/it2Setup.js';
|
||||
type SetupStep = 'initial' | 'installing' | 'install-failed' | 'verify-api' | 'api-instructions' | 'verifying' | 'success' | 'failed';
|
||||
import { Box, Text, useInput } from '../../ink.js'
|
||||
import { useKeybinding } from '../../keybindings/useKeybinding.js'
|
||||
import {
|
||||
detectPythonPackageManager,
|
||||
getPythonApiInstructions,
|
||||
installIt2,
|
||||
markIt2SetupComplete,
|
||||
type PythonPackageManager,
|
||||
setPreferTmuxOverIterm2,
|
||||
verifyIt2Setup,
|
||||
} from './backends/it2Setup.js'
|
||||
|
||||
type SetupStep =
|
||||
| 'initial'
|
||||
| 'installing'
|
||||
| 'install-failed'
|
||||
| 'verify-api'
|
||||
| 'api-instructions'
|
||||
| 'verifying'
|
||||
| 'success'
|
||||
| 'failed'
|
||||
|
||||
type Props = {
|
||||
onDone: (result: 'installed' | 'use-tmux' | 'cancelled') => void;
|
||||
tmuxAvailable: boolean;
|
||||
};
|
||||
export function It2SetupPrompt(t0) {
|
||||
const $ = _c(44);
|
||||
const {
|
||||
onDone,
|
||||
tmuxAvailable
|
||||
} = t0;
|
||||
const [step, setStep] = useState("initial");
|
||||
const [packageManager, setPackageManager] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const exitState = useExitOnCtrlCDWithKeybindings();
|
||||
let t1;
|
||||
let t2;
|
||||
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t1 = () => {
|
||||
detectPythonPackageManager().then(pm => {
|
||||
setPackageManager(pm);
|
||||
});
|
||||
};
|
||||
t2 = [];
|
||||
$[0] = t1;
|
||||
$[1] = t2;
|
||||
} else {
|
||||
t1 = $[0];
|
||||
t2 = $[1];
|
||||
}
|
||||
useEffect(t1, t2);
|
||||
let t3;
|
||||
if ($[2] !== onDone) {
|
||||
t3 = () => {
|
||||
onDone("cancelled");
|
||||
};
|
||||
$[2] = onDone;
|
||||
$[3] = t3;
|
||||
} else {
|
||||
t3 = $[3];
|
||||
}
|
||||
const handleCancel = t3;
|
||||
const t4 = step !== "installing" && step !== "verifying";
|
||||
let t5;
|
||||
if ($[4] !== t4) {
|
||||
t5 = {
|
||||
context: "Confirmation",
|
||||
isActive: t4
|
||||
};
|
||||
$[4] = t4;
|
||||
$[5] = t5;
|
||||
} else {
|
||||
t5 = $[5];
|
||||
}
|
||||
useKeybinding("confirm:no", handleCancel, t5);
|
||||
let t6;
|
||||
if ($[6] !== onDone || $[7] !== step) {
|
||||
t6 = (_input, key) => {
|
||||
if (step === "api-instructions" && key.return) {
|
||||
setStep("verifying");
|
||||
verifyIt2Setup().then(result => {
|
||||
if (result.success) {
|
||||
markIt2SetupComplete();
|
||||
setStep("success");
|
||||
setTimeout(onDone, 1500, "installed" as const);
|
||||
} else {
|
||||
setError(result.error || "Verification failed");
|
||||
setStep("failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
$[6] = onDone;
|
||||
$[7] = step;
|
||||
$[8] = t6;
|
||||
} else {
|
||||
t6 = $[8];
|
||||
}
|
||||
useInput(t6);
|
||||
let t7;
|
||||
if ($[9] !== packageManager) {
|
||||
t7 = async function handleInstall() {
|
||||
if (!packageManager) {
|
||||
setError("No Python package manager found (uvx, pipx, or pip)");
|
||||
setStep("failed");
|
||||
return;
|
||||
}
|
||||
setStep("installing");
|
||||
const result_0 = await installIt2(packageManager);
|
||||
if (result_0.success) {
|
||||
setStep("api-instructions");
|
||||
} else {
|
||||
setError(result_0.error || "Installation failed");
|
||||
setStep("install-failed");
|
||||
}
|
||||
};
|
||||
$[9] = packageManager;
|
||||
$[10] = t7;
|
||||
} else {
|
||||
t7 = $[10];
|
||||
}
|
||||
const handleInstall = t7;
|
||||
let t8;
|
||||
if ($[11] !== onDone) {
|
||||
t8 = function handleUseTmux() {
|
||||
setPreferTmuxOverIterm2(true);
|
||||
onDone("use-tmux");
|
||||
};
|
||||
$[11] = onDone;
|
||||
$[12] = t8;
|
||||
} else {
|
||||
t8 = $[12];
|
||||
}
|
||||
const handleUseTmux = t8;
|
||||
let T0;
|
||||
let T1;
|
||||
let t10;
|
||||
let t11;
|
||||
let t12;
|
||||
let t13;
|
||||
let t14;
|
||||
let t9;
|
||||
if ($[13] !== error || $[14] !== handleInstall || $[15] !== handleUseTmux || $[16] !== onDone || $[17] !== packageManager || $[18] !== step || $[19] !== tmuxAvailable) {
|
||||
const renderContent = () => {
|
||||
switch (step) {
|
||||
case "initial":
|
||||
{
|
||||
return renderInitialPrompt();
|
||||
}
|
||||
case "installing":
|
||||
{
|
||||
return renderInstalling();
|
||||
}
|
||||
case "install-failed":
|
||||
{
|
||||
return renderInstallFailed();
|
||||
}
|
||||
case "api-instructions":
|
||||
{
|
||||
return renderApiInstructions();
|
||||
}
|
||||
case "verifying":
|
||||
{
|
||||
return renderVerifying();
|
||||
}
|
||||
case "success":
|
||||
{
|
||||
return renderSuccess();
|
||||
}
|
||||
case "failed":
|
||||
{
|
||||
return renderFailed();
|
||||
}
|
||||
default:
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
function renderInitialPrompt() {
|
||||
const options = [{
|
||||
label: "Install it2 now",
|
||||
value: "install",
|
||||
description: packageManager ? `Uses ${packageManager} to install the it2 CLI tool` : "Requires Python (uvx, pipx, or pip)"
|
||||
}];
|
||||
if (tmuxAvailable) {
|
||||
options.push({
|
||||
label: "Use tmux instead",
|
||||
value: "tmux",
|
||||
description: "Opens teammates in a separate tmux session"
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
label: "Cancel",
|
||||
value: "cancel",
|
||||
description: "Skip teammate spawning for now"
|
||||
});
|
||||
return <Box flexDirection="column" gap={1}><Text>To use native iTerm2 split panes for teammates, you need the{" "}<Text bold={true}>it2</Text> CLI tool.</Text><Text dimColor={true}>This enables teammates to appear as split panes within your current window.</Text><Box marginTop={1}><Select options={options} onChange={value => {
|
||||
bb61: switch (value) {
|
||||
case "install":
|
||||
{
|
||||
handleInstall();
|
||||
break bb61;
|
||||
}
|
||||
case "tmux":
|
||||
{
|
||||
handleUseTmux();
|
||||
break bb61;
|
||||
}
|
||||
case "cancel":
|
||||
{
|
||||
onDone("cancelled");
|
||||
}
|
||||
}
|
||||
}} onCancel={() => onDone("cancelled")} /></Box></Box>;
|
||||
onDone: (result: 'installed' | 'use-tmux' | 'cancelled') => void
|
||||
tmuxAvailable: boolean
|
||||
}
|
||||
|
||||
export function It2SetupPrompt({
|
||||
onDone,
|
||||
tmuxAvailable,
|
||||
}: Props): React.ReactNode {
|
||||
const [step, setStep] = useState<SetupStep>('initial')
|
||||
const [packageManager, setPackageManager] =
|
||||
useState<PythonPackageManager | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const exitState = useExitOnCtrlCDWithKeybindings()
|
||||
|
||||
// Detect package manager on mount
|
||||
useEffect(() => {
|
||||
void detectPythonPackageManager().then(pm => {
|
||||
setPackageManager(pm)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
onDone('cancelled')
|
||||
}, [onDone])
|
||||
|
||||
useKeybinding('confirm:no', handleCancel, {
|
||||
context: 'Confirmation',
|
||||
isActive: step !== 'installing' && step !== 'verifying',
|
||||
})
|
||||
|
||||
// Handle keyboard input for verification step
|
||||
useInput((_input, key) => {
|
||||
if (step === 'api-instructions' && key.return) {
|
||||
setStep('verifying')
|
||||
void verifyIt2Setup().then(result => {
|
||||
if (result.success) {
|
||||
markIt2SetupComplete()
|
||||
setStep('success')
|
||||
setTimeout(onDone, 1500, 'installed' as const)
|
||||
} else {
|
||||
setError(result.error || 'Verification failed')
|
||||
setStep('failed')
|
||||
}
|
||||
})
|
||||
}
|
||||
function renderInstalling() {
|
||||
return <Box flexDirection="column" gap={1}><Box><Spinner /><Text> Installing it2 using {packageManager}…</Text></Box><Text dimColor={true}>This may take a moment.</Text></Box>;
|
||||
})
|
||||
|
||||
// Handle installation
|
||||
async function handleInstall(): Promise<void> {
|
||||
if (!packageManager) {
|
||||
setError('No Python package manager found (uvx, pipx, or pip)')
|
||||
setStep('failed')
|
||||
return
|
||||
}
|
||||
function renderInstallFailed() {
|
||||
const options_0 = [{
|
||||
label: "Try again",
|
||||
value: "retry",
|
||||
description: "Retry the installation"
|
||||
}];
|
||||
if (tmuxAvailable) {
|
||||
options_0.push({
|
||||
label: "Use tmux instead",
|
||||
value: "tmux",
|
||||
description: "Falls back to tmux for teammate panes"
|
||||
});
|
||||
}
|
||||
options_0.push({
|
||||
label: "Cancel",
|
||||
value: "cancel",
|
||||
description: "Skip teammate spawning for now"
|
||||
});
|
||||
return <Box flexDirection="column" gap={1}><Text color="error">Installation failed</Text>{error && <Text dimColor={true}>{error}</Text>}<Text dimColor={true}>You can try installing manually:{" "}{packageManager === "uvx" ? "uv tool install it2" : packageManager === "pipx" ? "pipx install it2" : "pip install --user it2"}</Text><Box marginTop={1}><Select options={options_0} onChange={value_0 => {
|
||||
bb89: switch (value_0) {
|
||||
case "retry":
|
||||
{
|
||||
handleInstall();
|
||||
break bb89;
|
||||
}
|
||||
case "tmux":
|
||||
{
|
||||
handleUseTmux();
|
||||
break bb89;
|
||||
}
|
||||
case "cancel":
|
||||
{
|
||||
onDone("cancelled");
|
||||
}
|
||||
}
|
||||
}} onCancel={() => onDone("cancelled")} /></Box></Box>;
|
||||
}
|
||||
function renderApiInstructions() {
|
||||
const instructions = getPythonApiInstructions();
|
||||
return <Box flexDirection="column" gap={1}><Text color="success">✓ it2 installed successfully</Text><Box flexDirection="column" marginTop={1}>{instructions.map(_temp)}</Box><Box marginTop={1}><Text dimColor={true}>Press Enter when ready to verify…</Text></Box></Box>;
|
||||
}
|
||||
function renderVerifying() {
|
||||
return <Box><Spinner /><Text> Verifying it2 can communicate with iTerm2…</Text></Box>;
|
||||
}
|
||||
function renderSuccess() {
|
||||
return <Box flexDirection="column"><Text color="success">✓ iTerm2 split pane support is ready</Text><Text dimColor={true}>Teammates will now appear as split panes.</Text></Box>;
|
||||
}
|
||||
function renderFailed() {
|
||||
const options_1 = [{
|
||||
label: "Try again",
|
||||
value: "retry",
|
||||
description: "Verify the connection again"
|
||||
}];
|
||||
if (tmuxAvailable) {
|
||||
options_1.push({
|
||||
label: "Use tmux instead",
|
||||
value: "tmux",
|
||||
description: "Falls back to tmux for teammate panes"
|
||||
});
|
||||
}
|
||||
options_1.push({
|
||||
label: "Cancel",
|
||||
value: "cancel",
|
||||
description: "Skip teammate spawning for now"
|
||||
});
|
||||
return <Box flexDirection="column" gap={1}><Text color="error">Verification failed</Text>{error && <Text dimColor={true}>{error}</Text>}<Text>Make sure:</Text><Box flexDirection="column" paddingLeft={2}><Text>· Python API is enabled in iTerm2 preferences</Text><Text>· You may need to restart iTerm2 after enabling</Text></Box><Box marginTop={1}><Select options={options_1} onChange={value_1 => {
|
||||
bb115: switch (value_1) {
|
||||
case "retry":
|
||||
{
|
||||
setStep("verifying");
|
||||
verifyIt2Setup().then(result_1 => {
|
||||
if (result_1.success) {
|
||||
markIt2SetupComplete();
|
||||
setStep("success");
|
||||
setTimeout(onDone, 1500, "installed" as const);
|
||||
} else {
|
||||
setError(result_1.error || "Verification failed");
|
||||
setStep("failed");
|
||||
}
|
||||
});
|
||||
break bb115;
|
||||
}
|
||||
case "tmux":
|
||||
{
|
||||
handleUseTmux();
|
||||
break bb115;
|
||||
}
|
||||
case "cancel":
|
||||
{
|
||||
onDone("cancelled");
|
||||
}
|
||||
}
|
||||
}} onCancel={() => onDone("cancelled")} /></Box></Box>;
|
||||
}
|
||||
T1 = Pane;
|
||||
t14 = "permission";
|
||||
T0 = Box;
|
||||
t9 = "column";
|
||||
t10 = 1;
|
||||
t11 = 1;
|
||||
if ($[28] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t12 = <Text bold={true} color="permission">iTerm2 Split Pane Setup</Text>;
|
||||
$[28] = t12;
|
||||
|
||||
setStep('installing')
|
||||
const result = await installIt2(packageManager)
|
||||
|
||||
if (result.success) {
|
||||
// Show Python API instructions
|
||||
setStep('api-instructions')
|
||||
} else {
|
||||
t12 = $[28];
|
||||
setError(result.error || 'Installation failed')
|
||||
setStep('install-failed')
|
||||
}
|
||||
t13 = renderContent();
|
||||
$[13] = error;
|
||||
$[14] = handleInstall;
|
||||
$[15] = handleUseTmux;
|
||||
$[16] = onDone;
|
||||
$[17] = packageManager;
|
||||
$[18] = step;
|
||||
$[19] = tmuxAvailable;
|
||||
$[20] = T0;
|
||||
$[21] = T1;
|
||||
$[22] = t10;
|
||||
$[23] = t11;
|
||||
$[24] = t12;
|
||||
$[25] = t13;
|
||||
$[26] = t14;
|
||||
$[27] = t9;
|
||||
} else {
|
||||
T0 = $[20];
|
||||
T1 = $[21];
|
||||
t10 = $[22];
|
||||
t11 = $[23];
|
||||
t12 = $[24];
|
||||
t13 = $[25];
|
||||
t14 = $[26];
|
||||
t9 = $[27];
|
||||
}
|
||||
let t15;
|
||||
if ($[29] !== exitState || $[30] !== step) {
|
||||
t15 = step !== "installing" && step !== "verifying" && step !== "success" && <Text dimColor={true} italic={true}>{exitState.pending ? <>Press {exitState.keyName} again to exit</> : <>Esc to cancel</>}</Text>;
|
||||
$[29] = exitState;
|
||||
$[30] = step;
|
||||
$[31] = t15;
|
||||
} else {
|
||||
t15 = $[31];
|
||||
|
||||
// Handle using tmux instead
|
||||
function handleUseTmux(): void {
|
||||
setPreferTmuxOverIterm2(true)
|
||||
onDone('use-tmux')
|
||||
}
|
||||
let t16;
|
||||
if ($[32] !== T0 || $[33] !== t10 || $[34] !== t11 || $[35] !== t12 || $[36] !== t13 || $[37] !== t15 || $[38] !== t9) {
|
||||
t16 = <T0 flexDirection={t9} gap={t10} paddingBottom={t11}>{t12}{t13}{t15}</T0>;
|
||||
$[32] = T0;
|
||||
$[33] = t10;
|
||||
$[34] = t11;
|
||||
$[35] = t12;
|
||||
$[36] = t13;
|
||||
$[37] = t15;
|
||||
$[38] = t9;
|
||||
$[39] = t16;
|
||||
} else {
|
||||
t16 = $[39];
|
||||
|
||||
// Render based on current step
|
||||
const renderContent = (): React.ReactNode => {
|
||||
switch (step) {
|
||||
case 'initial':
|
||||
return renderInitialPrompt()
|
||||
case 'installing':
|
||||
return renderInstalling()
|
||||
case 'install-failed':
|
||||
return renderInstallFailed()
|
||||
case 'api-instructions':
|
||||
return renderApiInstructions()
|
||||
case 'verifying':
|
||||
return renderVerifying()
|
||||
case 'success':
|
||||
return renderSuccess()
|
||||
case 'failed':
|
||||
return renderFailed()
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
let t17;
|
||||
if ($[40] !== T1 || $[41] !== t14 || $[42] !== t16) {
|
||||
t17 = <T1 color={t14}>{t16}</T1>;
|
||||
$[40] = T1;
|
||||
$[41] = t14;
|
||||
$[42] = t16;
|
||||
$[43] = t17;
|
||||
} else {
|
||||
t17 = $[43];
|
||||
|
||||
function renderInitialPrompt(): React.ReactNode {
|
||||
const options: OptionWithDescription<string>[] = [
|
||||
{
|
||||
label: 'Install it2 now',
|
||||
value: 'install',
|
||||
description: packageManager
|
||||
? `Uses ${packageManager} to install the it2 CLI tool`
|
||||
: 'Requires Python (uvx, pipx, or pip)',
|
||||
},
|
||||
]
|
||||
|
||||
if (tmuxAvailable) {
|
||||
options.push({
|
||||
label: 'Use tmux instead',
|
||||
value: 'tmux',
|
||||
description: 'Opens teammates in a separate tmux session',
|
||||
})
|
||||
}
|
||||
|
||||
options.push({
|
||||
label: 'Cancel',
|
||||
value: 'cancel',
|
||||
description: 'Skip teammate spawning for now',
|
||||
})
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>
|
||||
To use native iTerm2 split panes for teammates, you need the{' '}
|
||||
<Text bold>it2</Text> CLI tool.
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
This enables teammates to appear as split panes within your current
|
||||
window.
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Select
|
||||
options={options}
|
||||
onChange={value => {
|
||||
switch (value) {
|
||||
case 'install':
|
||||
void handleInstall()
|
||||
break
|
||||
case 'tmux':
|
||||
handleUseTmux()
|
||||
break
|
||||
case 'cancel':
|
||||
onDone('cancelled')
|
||||
break
|
||||
}
|
||||
}}
|
||||
onCancel={() => onDone('cancelled')}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
return t17;
|
||||
}
|
||||
function _temp(line, i) {
|
||||
return <Text key={i}>{line}</Text>;
|
||||
|
||||
function renderInstalling(): React.ReactNode {
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box>
|
||||
<Spinner />
|
||||
<Text> Installing it2 using {packageManager}…</Text>
|
||||
</Box>
|
||||
<Text dimColor>This may take a moment.</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function renderInstallFailed(): React.ReactNode {
|
||||
const options: OptionWithDescription<string>[] = [
|
||||
{
|
||||
label: 'Try again',
|
||||
value: 'retry',
|
||||
description: 'Retry the installation',
|
||||
},
|
||||
]
|
||||
|
||||
if (tmuxAvailable) {
|
||||
options.push({
|
||||
label: 'Use tmux instead',
|
||||
value: 'tmux',
|
||||
description: 'Falls back to tmux for teammate panes',
|
||||
})
|
||||
}
|
||||
|
||||
options.push({
|
||||
label: 'Cancel',
|
||||
value: 'cancel',
|
||||
description: 'Skip teammate spawning for now',
|
||||
})
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="error">Installation failed</Text>
|
||||
{error && <Text dimColor>{error}</Text>}
|
||||
<Text dimColor>
|
||||
You can try installing manually:{' '}
|
||||
{packageManager === 'uvx'
|
||||
? 'uv tool install it2'
|
||||
: packageManager === 'pipx'
|
||||
? 'pipx install it2'
|
||||
: 'pip install --user it2'}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Select
|
||||
options={options}
|
||||
onChange={value => {
|
||||
switch (value) {
|
||||
case 'retry':
|
||||
void handleInstall()
|
||||
break
|
||||
case 'tmux':
|
||||
handleUseTmux()
|
||||
break
|
||||
case 'cancel':
|
||||
onDone('cancelled')
|
||||
break
|
||||
}
|
||||
}}
|
||||
onCancel={() => onDone('cancelled')}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function renderApiInstructions(): React.ReactNode {
|
||||
const instructions = getPythonApiInstructions()
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="success">✓ it2 installed successfully</Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{instructions.map((line, i) => (
|
||||
<Text key={i}>{line}</Text>
|
||||
))}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>Press Enter when ready to verify…</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function renderVerifying(): React.ReactNode {
|
||||
return (
|
||||
<Box>
|
||||
<Spinner />
|
||||
<Text> Verifying it2 can communicate with iTerm2…</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function renderSuccess(): React.ReactNode {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color="success">✓ iTerm2 split pane support is ready</Text>
|
||||
<Text dimColor>Teammates will now appear as split panes.</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
function renderFailed(): React.ReactNode {
|
||||
const options: OptionWithDescription<string>[] = [
|
||||
{
|
||||
label: 'Try again',
|
||||
value: 'retry',
|
||||
description: 'Verify the connection again',
|
||||
},
|
||||
]
|
||||
|
||||
if (tmuxAvailable) {
|
||||
options.push({
|
||||
label: 'Use tmux instead',
|
||||
value: 'tmux',
|
||||
description: 'Falls back to tmux for teammate panes',
|
||||
})
|
||||
}
|
||||
|
||||
options.push({
|
||||
label: 'Cancel',
|
||||
value: 'cancel',
|
||||
description: 'Skip teammate spawning for now',
|
||||
})
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="error">Verification failed</Text>
|
||||
{error && <Text dimColor>{error}</Text>}
|
||||
<Text>Make sure:</Text>
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
<Text>· Python API is enabled in iTerm2 preferences</Text>
|
||||
<Text>· You may need to restart iTerm2 after enabling</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Select
|
||||
options={options}
|
||||
onChange={value => {
|
||||
switch (value) {
|
||||
case 'retry':
|
||||
setStep('verifying')
|
||||
void verifyIt2Setup().then(result => {
|
||||
if (result.success) {
|
||||
markIt2SetupComplete()
|
||||
setStep('success')
|
||||
setTimeout(onDone, 1500, 'installed' as const)
|
||||
} else {
|
||||
setError(result.error || 'Verification failed')
|
||||
setStep('failed')
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'tmux':
|
||||
handleUseTmux()
|
||||
break
|
||||
case 'cancel':
|
||||
onDone('cancelled')
|
||||
break
|
||||
}
|
||||
}}
|
||||
onCancel={() => onDone('cancelled')}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Pane color="permission">
|
||||
<Box flexDirection="column" gap={1} paddingBottom={1}>
|
||||
<Text bold color="permission">
|
||||
iTerm2 Split Pane Setup
|
||||
</Text>
|
||||
{renderContent()}
|
||||
{step !== 'installing' &&
|
||||
step !== 'verifying' &&
|
||||
step !== 'success' && (
|
||||
<Text dimColor italic>
|
||||
{exitState.pending ? (
|
||||
<>Press {exitState.keyName} again to exit</>
|
||||
) : (
|
||||
<>Esc to cancel</>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Pane>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user